Skip to content

Commit

Permalink
feat: updating ts deployer to use v8 utils; updating py to support v3…
Browse files Browse the repository at this point in the history
… utils-py (#94)

* refactor: updating ts deployer to use v8 utils; updating py to support v3 utils-py

* refactor: further polishing

* refactor: remove black/flake-8 from root setup

* chore: addressing pr comments

* chore: enable auto import completions in pylance

* chore: tweak ts utils dependencies

* chore: bump to prod versions

---------

Co-authored-by: Neil Campbell <neil.campbell@makerx.com.au>
  • Loading branch information
aorumbayev and neilcampbell authored Feb 18, 2025
1 parent e33861d commit ef72612
Show file tree
Hide file tree
Showing 95 changed files with 1,997 additions and 2,646 deletions.
7 changes: 1 addition & 6 deletions .github/workflows/check-python.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,10 @@ jobs:
- name: Install dependencies
run: poetry env use 3.12 && poetry install --no-interaction --no-root

- name: Check formatting with Black
run: |
# stop the build if there are files that don't meet formatting requirements
poetry run black --check .
- name: Check linting with Ruff
run: |
# stop the build if there are Python syntax errors or undefined names
poetry run ruff .
poetry run ruff check .
- name: Configure git
shell: bash
Expand Down
11 changes: 1 addition & 10 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
repos:
- repo: local
hooks:
- id: black
name: black
description: "Black: The uncompromising Python code formatter"
entry: poetry run black
language: system
minimum_pre_commit_version: 2.9.2
require_serial: true
types_or: [ python, pyi ]
- id: ruff
name: ruff
description: "Run 'ruff' for extremely fast Python linting"
entry: poetry run ruff
entry: poetry run ruff check --fix
language: system
'types': [python]
args: [--fix]
require_serial: false
additional_dependencies: []
minimum_pre_commit_version: '0'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ test = { commands = [
], description = 'Run smart contract tests' }
audit = { commands = [
'poetry run pip-audit',
], description = 'Audit with pip-audit' }
], description = 'Audit with pip-audit. NOTE: If used with poetry >v2, make sure to install `poetry-plugin-export` as per https://github.com/python-poetry/poetry-plugin-export#installation.' }
lint = { commands = [
'poetry run black --check --diff .',
'poetry run ruff check .',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,44 @@
import logging

import algokit_utils
from algosdk.v2client.algod import AlgodClient
from algosdk.v2client.indexer import IndexerClient

logger = logging.getLogger(__name__)


# define deployment behaviour based on supplied app spec
def deploy(
algod_client: AlgodClient,
indexer_client: IndexerClient,
app_spec: algokit_utils.ApplicationSpecification,
deployer: algokit_utils.Account,
) -> None:
def deploy() -> None:
from smart_contracts.artifacts.{{ contract_name }}.{{ contract_name }}_client import (
{{ contract_name.split('_')|map('capitalize')|join }}Client,
{{ contract_name.split('_')|map('capitalize')|join }}Factory,
HelloArgs,
)

app_client = {{ contract_name.split('_')|map('capitalize')|join }}Client(
algod_client,
creator=deployer,
indexer_client=indexer_client,
algorand = algokit_utils.AlgorandClient.from_environment()
deployer_ = algorand.account.from_environment("DEPLOYER")

factory = algorand.client.get_typed_app_factory(
{{ contract_name.split('_')|map('capitalize')|join }}Factory, default_sender=deployer_.address
)
app_client.deploy(
on_schema_break=algokit_utils.OnSchemaBreak.AppendApp,

app_client, result = factory.deploy(
on_update=algokit_utils.OnUpdate.AppendApp,
on_schema_break=algokit_utils.OnSchemaBreak.AppendApp,
)

if result.operation_performed in [
algokit_utils.OperationPerformed.Create,
algokit_utils.OperationPerformed.Replace,
]:
algorand.send.payment(
algokit_utils.PaymentParams(
amount=algokit_utils.AlgoAmount(algo=1),
sender=deployer_.address,
receiver=app_client.app_address,
)
)

name = "world"
response = app_client.hello(name=name)
response = app_client.send.hello(args=HelloArgs(name=name))
logger.info(
f"Called hello on {app_spec.contract.name} ({app_client.app_id}) "
f"with name={name}, received: {response.return_value}"
f"Called hello on {app_client.app_name} ({app_client.app_id}) "
f"with name={name}, received: {response.abi_return}"
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ jobs:
uses: actions/checkout@v4

- name: Install poetry
run: pipx install poetry
run: |
pipx install poetry
pipx inject poetry poetry-plugin-export
- name: Set up Python 3.12
uses: actions/setup-python@v5
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ jobs:
uses: actions/checkout@v4

- name: Install poetry
run: pipx install poetry
run: |
pipx install poetry
pipx inject poetry poetry-plugin-export
- name: Set up Python 3.12
uses: actions/setup-python@v5
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
},

// Python
"python.analysis.autoImportCompletions": true,
"python.analysis.extraPaths": ["${workspaceFolder}/smart_contracts"],
"python.analysis.diagnosticSeverityOverrides": {
"reportMissingModuleSource": "none"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ readme = "README.md"

[tool.poetry.dependencies]
python = "^3.12"
algokit-utils = "^2.4.0"
algokit-utils = "^3.0.0"
python-dotenv = "^1.0.0"
algorand-python = "^2.0.0"
algorand-python-testing = "^0.4.0"

[tool.poetry.group.dev.dependencies]
algokit-client-generator = "^1.1.3"
algokit-client-generator = "^2.0.0"
black = {extras = ["d"], version = "*"}
ruff = "^0.1.6"
mypy = "1.11.0"
ruff = "^0.9.4"
mypy = "^1"
pytest = "*"
pytest-cov = "*"
pip-audit = "*"
Expand All @@ -29,14 +29,10 @@ build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 120
select = ["E", "F", "ANN", "UP", "N", "C4", "B", "A", "YTT", "W", "FBT", "Q", "RUF", "I"]
ignore = [
"ANN101", # no type for self
"ANN102", # no type for cls
]
unfixable = ["B", "RUF"]
lint.select = ["E", "F", "ANN", "UP", "N", "C4", "B", "A", "YTT", "W", "FBT", "Q", "RUF", "I"]
lint.unfixable = ["B", "RUF"]

[tool.ruff.flake8-annotations]
[tool.ruff.lint.flake8-annotations]
allow-star-arg-any = true
suppress-none-returning = true

Expand Down
Original file line number Diff line number Diff line change
@@ -1,35 +1,173 @@
import dataclasses
import importlib
import logging
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path
from shutil import rmtree

from algokit_utils.config import config
from dotenv import load_dotenv

from smart_contracts._helpers.build import build
from smart_contracts._helpers.config import contracts
from smart_contracts._helpers.deploy import deploy

# Uncomment the following lines to enable auto generation of AVM Debugger compliant sourcemap and simulation trace file.
# Set trace_all to True to capture all transactions, defaults to capturing traces only on failure
# Learn more about using AlgoKit AVM Debugger to debug your TEAL source codes and inspect various kinds of
# Algorand transactions in atomic groups -> https://github.com/algorandfoundation/algokit-avm-vscode-debugger
# from algokit_utils.config import config
# config.configure(debug=True, trace_all=True)
config.configure(debug=True, trace_all=False)

# Set up logging and load environment variables.
logging.basicConfig(
level=logging.DEBUG, format="%(asctime)s %(levelname)-10s: %(message)s"
)
logger = logging.getLogger(__name__)
logger.info("Loading .env")
# For manual script execution (bypassing `algokit project deploy`) with a custom .env,
# modify `load_dotenv()` accordingly. For example, `load_dotenv('.env.localnet')`.
load_dotenv()

# Determine the root path based on this file's location.
root_path = Path(__file__).parent

# ----------------------- Contract Configuration ----------------------- #


@dataclasses.dataclass
class SmartContract:
path: Path
name: str
deploy: Callable[[], None] | None = None


def import_contract(folder: Path) -> Path:
"""Imports the contract from a folder if it exists."""
contract_path = folder / "contract.py"
if contract_path.exists():
return contract_path
else:
raise Exception(f"Contract not found in {folder}")


def import_deploy_if_exists(folder: Path) -> Callable[[], None] | None:
"""Imports the deploy function from a folder if it exists."""
try:
module_name = f"{folder.parent.name}.{folder.name}.deploy_config"
deploy_module = importlib.import_module(module_name)
return deploy_module.deploy # type: ignore[no-any-return, misc]
except ImportError:
return None


def has_contract_file(directory: Path) -> bool:
"""Checks whether the directory contains a contract.py file."""
return (directory / "contract.py").exists()


# Use the current directory (root_path) as the base for contract folders and exclude
# folders that start with '_' (internal helpers).
contracts: list[SmartContract] = [
SmartContract(
path=import_contract(folder),
name=folder.name,
deploy=import_deploy_if_exists(folder),
)
for folder in root_path.iterdir()
if folder.is_dir() and has_contract_file(folder) and not folder.name.startswith("_")
]

# -------------------------- Build Logic -------------------------- #

deployment_extension = "py"


def _get_output_path(output_dir: Path, deployment_extension: str) -> Path:
"""Constructs the output path for the generated client file."""
return output_dir / Path(
"{contract_name}"
+ ("_client" if deployment_extension == "py" else "Client")
+ f".{deployment_extension}"
)


def build(output_dir: Path, contract_path: Path) -> Path:
"""
Builds the contract by exporting (compiling) its source and generating a client.
If the output directory already exists, it is cleared.
"""
output_dir = output_dir.resolve()
if output_dir.exists():
rmtree(output_dir)
output_dir.mkdir(exist_ok=True, parents=True)
logger.info(f"Exporting {contract_path} to {output_dir}")

build_result = subprocess.run(
[
"algokit",
"--no-color",
"compile",
"python",
str(contract_path.resolve()),
f"--out-dir={output_dir}",
"--no-output-arc32",
"--output-arc56",
"--output-source-map",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if build_result.returncode:
raise Exception(f"Could not build contract:\n{build_result.stdout}")

# Look for arc56.json files and generate the client based on them.
app_spec_file_names: list[str] = [
file.name for file in output_dir.glob("*.arc56.json")
]

client_file: str | None = None
if not app_spec_file_names:
logger.warning(
"No '*.arc56.json' file found (likely a logic signature being compiled). Skipping client generation."
)
else:
for file_name in app_spec_file_names:
client_file = file_name
print(file_name)
generate_result = subprocess.run(
[
"algokit",
"generate",
"client",
str(output_dir),
"--output",
str(_get_output_path(output_dir, deployment_extension)),
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
if generate_result.returncode:
if "No such command" in generate_result.stdout:
raise Exception(
"Could not generate typed client, requires AlgoKit 2.0.0 or later. Please update AlgoKit"
)
else:
raise Exception(
f"Could not generate typed client:\n{generate_result.stdout}"
)
if client_file:
return output_dir / client_file
return output_dir


# --------------------------- Main Logic --------------------------- #


def main(action: str, contract_name: str | None = None) -> None:
"""Main entry point to build and/or deploy smart contracts."""
artifact_path = root_path / "artifacts"

# Filter contracts if a specific contract name is provided
# Filter contracts based on an optional specific contract name.
filtered_contracts = [
c for c in contracts if contract_name is None or c.name == contract_name
contract
for contract in contracts
if contract_name is None or contract.name == contract_name
]

match action:
Expand All @@ -44,23 +182,24 @@ def main(action: str, contract_name: str | None = None) -> None:
(
file.name
for file in output_dir.iterdir()
if file.is_file() and file.suffixes == [".arc32", ".json"]
if file.is_file() and file.suffixes == [".arc56", ".json"]
),
None,
)
if app_spec_file_name is None:
raise Exception("Could not deploy app, .arc32.json file not found")
app_spec_path = output_dir / app_spec_file_name
raise Exception("Could not deploy app, .arc56.json file not found")
if contract.deploy:
logger.info(f"Deploying app {contract.name}")
deploy(app_spec_path, contract.deploy)
contract.deploy()
case "all":
for contract in filtered_contracts:
logger.info(f"Building app at {contract.path}")
app_spec_path = build(artifact_path / contract.name, contract.path)
build(artifact_path / contract.name, contract.path)
if contract.deploy:
logger.info(f"Deploying {contract.path.name}")
deploy(app_spec_path, contract.deploy)
logger.info(f"Deploying {contract.name}")
contract.deploy()
case _:
logger.error(f"Unknown action: {action}")


if __name__ == "__main__":
Expand Down
Empty file.
Loading

0 comments on commit ef72612

Please sign in to comment.