Skip to content

Commit

Permalink
chore: mint task enhancement (#543)
Browse files Browse the repository at this point in the history
* chore: ask for mnemonic right after provided account address

* chore: make the error visible in the beginning

* fix: check for NFT validation error

* fix: asset name and decimal validations

* fix: on callbacks running twice
  • Loading branch information
negar-abbasi authored Aug 14, 2024
1 parent 5af8092 commit 760455d
Show file tree
Hide file tree
Showing 8 changed files with 315 additions and 85 deletions.
18 changes: 9 additions & 9 deletions docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,14 @@
- [mint](#mint)
- [Options](#options-25)
- [--creator ](#--creator-)
- [-n, --name ](#-n---name--3)
- [--name ](#--name-)
- [-u, --unit ](#-u---unit-)
- [-t, --total ](#-t---total-)
- [-d, --decimals ](#-d---decimals-)
- [--nft, --ft](#--nft---ft)
- [-i, --image ](#-i---image-)
- [-m, --metadata ](#-m---metadata-)
- [--mutable, --immutable](#--mutable---immutable)
- [--nft, --ft](#--nft---ft)
- [-n, --network ](#-n---network-)
- [nfd-lookup](#nfd-lookup)
- [Options](#options-26)
Expand Down Expand Up @@ -1111,8 +1111,8 @@ algokit task mint [OPTIONS]
**Required** Address or alias of the asset creator.


### -n, --name <asset_name>
**Required** Asset name.
### --name <asset_name>
Asset name.


### -u, --unit <unit_name>
Expand All @@ -1127,6 +1127,11 @@ Total supply of the asset. Defaults to 1.
Number of decimals. Defaults to 0.


### --nft, --ft
Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical
definitions of pure or fractional NFTs as per ARC3 standard.


### -i, --image <image_path>
**Required** Path to the asset image file to be uploaded to IPFS.

Expand All @@ -1141,11 +1146,6 @@ For more details refer to [https://arc.algorand.foundation/ARCs/arc-0003#json-me
Whether the asset should be mutable or immutable. Refers to ARC19 by default.


### --nft, --ft
Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical
definitions of pure or fractional NFTs as per ARC3 standard.


### -n, --network <network>
Network to use. Refers to localnet by default.

Expand Down
154 changes: 120 additions & 34 deletions src/algokit/cli/tasks/mint.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import json
import logging
import math
from pathlib import Path

import click
from algokit_utils import Account
from algosdk.error import AlgodHTTPError
from algosdk.util import algos_to_microalgos

Expand All @@ -11,6 +13,7 @@
from algokit.cli.tasks.utils import (
get_account_with_private_key,
load_algod_client,
run_callback_once,
validate_balance,
)
from algokit.core.tasks.ipfs import (
Expand Down Expand Up @@ -72,18 +75,37 @@ def _validate_unit_name(context: click.Context, param: click.Parameter, value: s
)


def _validate_asset_name(context: click.Context, param: click.Parameter, value: str) -> str:
def _get_and_validate_asset_name(context: click.Context, param: click.Parameter, value: str | None) -> str:
"""
Validate the asset name by checking if its byte length is less than or equal to a predefined maximum value.
If asset name has not been supplied in the metadata file or via an argument a prompt is displayed.
Args:
context (click.Context): The click context.
param (click.Parameter): The click parameter.
value (str): The value of the parameter.
value (str|None): The value of the parameter.
Returns:
str: The value of the parameter if it passes the validation.
"""
token_metadata_path = context.params.get("token_metadata_path")
token_name = None

if token_metadata_path is not None:
with Path(token_metadata_path).open("r") as metadata_file:
data = json.load(metadata_file)
token_name = data.get("name")

if value is None:
if token_name is None:
value = click.prompt("Provide the asset name", type=str)
else:
value = token_name
elif token_name is not None and token_name != value:
raise click.BadParameter("Token name in metadata JSON must match CLI argument providing token name!")

if value is None:
raise click.BadParameter("Asset name cannot be None")

if len(value.encode("utf-8")) <= MAX_ASSET_NAME_BYTE_LENGTH:
return value
Expand All @@ -93,6 +115,75 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value:
)


def _get_creator_account(_: click.Context, __: click.Parameter, value: str) -> Account:
"""
Validate the creator account by checking if it is a valid Algorand address.
Args:
context (click.Context): The click context.
value (str): The value of the parameter.
Returns:
Account: An account object with the address and private key.
"""
try:
return get_account_with_private_key(value)
except Exception as ex:
raise click.BadParameter(str(ex)) from ex


def _get_and_validate_decimals(context: click.Context, _: click.Parameter, value: int | None) -> int:
"""
Validate the number of decimal places for the token.
If decimals has not been supplied in the metadata file or via an argument a prompt is displayed.
Args:
context (click.Context): The click context.
value (int|None): The value of the parameter.
Returns:
int: The value of the parameter if it passes the validation.
"""
token_metadata_path = context.params.get("token_metadata_path")
token_decimals = None
if token_metadata_path is not None:
with Path(token_metadata_path).open("r") as metadata_file:
data = json.load(metadata_file)
token_decimals = data.get("decimals")

if value is None:
if token_decimals is None:
decimals: int = click.prompt("Provide the asset decimals", type=int, default=0)
return decimals
return int(token_decimals)
else:
if token_decimals is not None and token_decimals != value:
raise click.BadParameter("The value for decimals in the metadata JSON must match the decimals argument.")
return value


def _validate_supply_for_nft(context: click.Context, _: click.Parameter, value: bool) -> bool: # noqa: FBT001
"""
Validate the total supply and decimal places for NFTs.
Args:
context (click.Context): The click context.
value (bool): The value of the parameter.
Returns:
bool: The value of the parameter if it passes the validation.
"""
if value:
try:
total = context.params.get("total")
decimals = context.params.get("decimals")
if total is not None and decimals is not None:
_validate_supply(total, decimals)
except click.ClickException as ex:
raise ex
return value


@click.command(
name="mint",
help="Mint new fungible or non-fungible assets on Algorand.",
Expand All @@ -103,26 +194,28 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value:
prompt="Provide the address or alias of the asset creator",
help="Address or alias of the asset creator.",
type=click.STRING,
callback=run_callback_once(_get_creator_account),
is_eager=True,
)
@click.option(
"-n",
"--name",
"asset_name",
type=click.STRING,
required=True,
callback=_validate_asset_name,
prompt="Provide the asset name",
required=False,
callback=_get_and_validate_asset_name,
help="Asset name.",
is_eager=True,
)
@click.option(
"-u",
"--unit",
"unit_name",
type=click.STRING,
required=True,
callback=_validate_unit_name,
callback=run_callback_once(_validate_unit_name),
prompt="Provide the unit name",
help="Unit name of the asset.",
is_eager=True,
)
@click.option(
"-t",
Expand All @@ -132,15 +225,26 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value:
default=1,
prompt="Provide the total supply",
help="Total supply of the asset. Defaults to 1.",
is_eager=True,
)
@click.option(
"-d",
"--decimals",
type=click.INT,
required=False,
default=0,
prompt="Provide the number of decimals",
callback=_get_and_validate_decimals,
help="Number of decimals. Defaults to 0.",
is_eager=True, # This option needs to be evaluated before nft option.
)
@click.option(
"--nft/--ft",
"non_fungible",
type=click.BOOL,
prompt="Validate asset as NFT? Checks values of `total` and `decimals` as per ARC3 if set to True.",
default=False,
callback=_validate_supply_for_nft,
help="""Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical
definitions of pure or fractional NFTs as per ARC3 standard.""",
)
@click.option(
"-i",
Expand Down Expand Up @@ -169,15 +273,6 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value:
default=False,
help="Whether the asset should be mutable or immutable. Refers to `ARC19` by default.",
)
@click.option(
"--nft/--ft",
"non_fungible",
type=click.BOOL,
prompt="Validate asset as NFT? Checks values of `total` and `decimals` as per ARC3 if set to True.",
default=False,
help="""Whether the asset should be validated as NFT or FT. Refers to NFT by default and validates canonical
definitions of pure or fractional NFTs as per ARC3 standard.""",
)
@click.option(
"-n",
"--network",
Expand All @@ -189,49 +284,40 @@ def _validate_asset_name(context: click.Context, param: click.Parameter, value:
)
def mint( # noqa: PLR0913
*,
creator: str,
creator: Account,
asset_name: str,
unit_name: str,
total: int,
decimals: int,
image_path: Path,
token_metadata_path: Path | None,
mutable: bool,
non_fungible: bool,
network: AlgorandNetwork,
non_fungible: bool, # noqa: ARG001
) -> None:
if non_fungible:
_validate_supply(total, decimals)

creator_account = get_account_with_private_key(creator)

pinata_jwt = get_pinata_jwt()
if not pinata_jwt:
raise click.ClickException("You are not logged in! Please login using `algokit task ipfs login`.")

client = load_algod_client(network)
validate_balance(
client,
creator_account,
creator,
0,
algos_to_microalgos(ASSET_MINTING_MBR), # type: ignore[no-untyped-call]
)

token_metadata = TokenMetadata.from_json_file(token_metadata_path)
if not token_metadata_path:
token_metadata.name = asset_name
token_metadata.decimals = decimals
token_metadata = TokenMetadata.from_json_file(token_metadata_path, asset_name, decimals)
try:
asset_id, txn_id = mint_token(
client=client,
jwt=pinata_jwt,
creator_account=creator_account,
creator_account=creator,
unit_name=unit_name,
total=total,
token_metadata=token_metadata,
image_path=image_path,
unit_name=unit_name,
asset_name=asset_name,
mutable=mutable,
total=total,
)

click.echo("\nSuccessfully minted the asset!")
Expand Down
27 changes: 27 additions & 0 deletions src/algokit/cli/tasks/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import os
import stat
import sys
from collections.abc import Callable
from functools import wraps
from typing import Any

import algosdk
import algosdk.encoding
Expand Down Expand Up @@ -297,3 +300,27 @@ def get_account_info(algod_client: algosdk.v2client.algod.AlgodClient, account_a
account_info = algod_client.account_info(account_address)
assert isinstance(account_info, dict)
return account_info


def run_callback_once(callback: Callable) -> Callable:
"""
Click option callbacks run twice, first to validate the prompt input,
and then independently from that is used to validate the value passed to the option.
In cases where the callback is expensive or has side effects(like prompting the user),
it's better to run it only once.
"""

@wraps(callback)
def wrapper(context: click.Context, param: click.Parameter, value: Any) -> Any: # noqa: ANN401
if context.obj is None:
context.obj = {}

key = f"{param.name}_callback_result"
if key not in context.obj:
result = callback(context, param, value)
context.obj[key] = result
return result
return context.obj[key]

return wrapper
Loading

1 comment on commit 760455d

@github-actions
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage

Coverage Report
FileStmtsMissCoverMissing
src/algokit
   __init__.py15753%6–13, 17–24, 32–34
   __main__.py440%1–7
src/algokit/cli
   __init__.py47394%31–34
   codespace.py50982%28, 114, 137, 150–155
   completions.py108992%63–64, 83, 93–99
   dispenser.py121199%77
   doctor.py53394%146–148
   explore.py631576%35–40, 42–47, 85–92, 113
   generate.py70396%76–77, 155
   goal.py47198%79
   init.py3112492%497–498, 503–504, 507, 528, 531–533, 544, 548, 606, 632, 661, 694, 703–705, 708–713, 726, 745, 757–758
   localnet.py1523279%65, 86–113, 133–137, 170, 182, 197–207, 220, 271, 292–293
   task.py34391%25–28
src/algokit/cli/project
   bootstrap.py32197%33
   deploy.py992080%47, 49, 101, 124, 146–148, 227, 234, 248–256, 259–268
   link.py891682%60, 65–66, 101–105, 115–120, 148–149, 218–219, 223
   list.py33585%21–23, 51–56
   run.py46393%38, 71, 160
src/algokit/cli/tasks
   analyze.py81199%81
   assets.py821384%65–66, 72, 74–75, 105, 119, 125–126, 132, 134, 136–137
   ipfs.py51884%52, 80, 92, 94–95, 105–107
   mint.py1061586%51, 73, 100–103, 108, 113, 131–132, 158, 335–339
   send_transaction.py651085%52–53, 57, 89, 158, 170–174
   sign_transaction.py59886%21, 28–30, 71–72, 109, 123
   transfer.py39392%26, 90, 117
   utils.py1144660%29–37, 43–46, 78–79, 103–104, 128–136, 155–165, 212, 261–262, 282–293, 300–302, 324
   vanity_address.py561082%41, 45–48, 112, 114, 121–123
   wallet.py79495%21, 66, 136, 162
src/algokit/core
   codespace.py1756861%34–37, 41–44, 48–71, 111–112, 125–133, 191, 200–202, 210, 216–217, 229–236, 251–298, 311–313, 338–344, 348, 395
   conf.py57984%12, 24, 28, 36, 38, 73–75, 80
   dispenser.py2022687%91, 123–124, 141–149, 191–192, 198–200, 218–219, 259–260, 318, 332–334, 345–346, 356, 369, 384
   doctor.py65789%67–69, 92–94, 134
   generate.py50394%44, 85, 103
   goal.py65494%21, 36–37, 47
   init.py671085%53, 57–62, 70, 81, 88, 108–109
   log_handlers.py68790%50–51, 63, 112–116, 125
   proc.py45198%99
   sandbox.py2632391%32, 89–92, 97, 101–103, 153, 201–208, 219, 590, 606, 631, 639
   typed_client_generation.py1702088%62–64, 103–108, 132, 135–138, 156, 159–162, 229, 232–235
   utils.py1504073%50–51, 57–69, 125–131, 155, 158, 164–177, 206–208, 237–240, 262
src/algokit/core/compilers
   python.py28582%19–20, 25, 49–50
src/algokit/core/config_commands
   container_engine.py412149%24, 29–31, 47–76
   version_prompt.py921485%37–38, 68, 87–90, 108, 118–125, 148
src/algokit/core/project
   __init__.py53394%50, 86, 145
   bootstrap.py120893%47, 126–127, 149, 176, 207–209
   deploy.py69987%108–111, 120–122, 126, 131
   run.py1251588%83, 88, 97–98, 133–134, 138–139, 143, 147, 261–269, 284
src/algokit/core/tasks
   analyze.py93397%105–112, 187
   ipfs.py63789%58–64, 140, 144, 146, 152
   nfd.py491373%25, 31, 34–41, 70–72, 99–101
   vanity_address.py903462%49–50, 54, 59–75, 92–108, 128–131
   wallet.py71593%37, 129, 155–157
src/algokit/core/tasks/mint
   mint.py74988%123–133
   models.py921782%50, 52, 57, 71–74, 81–90
TOTAL471465886% 

Tests Skipped Failures Errors Time
492 0 💤 0 ❌ 0 🔥 26.623s ⏱️

Please sign in to comment.