Skip to content

Commit

Permalink
feat: add command to compile python to TEAL with Puyapy
Browse files Browse the repository at this point in the history
  • Loading branch information
PatrickDinh authored Mar 11, 2024
1 parent 1a15d5d commit 1030799
Show file tree
Hide file tree
Showing 12 changed files with 353 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .github/workflows/build-python.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ jobs:
# track here -> https://github.com/crytic/tealer/pull/209
run: poetry install --no-interaction && pipx install tealer==0.1.2

- name: Install PuyaPy
if: ${{ matrix.python == '3.12' }}
run: pipx install puya

- name: pytest
shell: bash
if: ${{ !(matrix.python == '3.12' && matrix.os == 'ubuntu-latest') }}
Expand Down
2 changes: 2 additions & 0 deletions src/algokit/cli/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import click

from algokit.cli.bootstrap import bootstrap_group
from algokit.cli.compile import compile_group
from algokit.cli.completions import completions_group
from algokit.cli.config import config_group
from algokit.cli.deploy import deploy_command
Expand Down Expand Up @@ -49,3 +50,4 @@ def algokit(*, skip_version_check: bool) -> None:
algokit.add_command(deploy_command)
algokit.add_command(dispenser_group)
algokit.add_command(task_group)
algokit.add_command(compile_group)
67 changes: 67 additions & 0 deletions src/algokit/cli/compile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import logging

import click

from algokit.core.compile.python import find_valid_puyapy_command
from algokit.core.proc import run

logger = logging.getLogger(__name__)


@click.group("compile", hidden=True)
@click.option(
"-v",
"--version",
"version",
required=False,
default=None,
help=(
"Compiler version, for example, 1.0.0. "
"If the version isn't specified, AlgoKit will check if the compiler is installed locally, and execute that. "
"If the compiler is not found, it will install the latest version. "
"If the version is specified, AlgoKit will check if the local compiler's version satisfies, and execute that. "
"Otherwise, AlgoKit will install the specifed compiler version."
),
)
@click.pass_context
def compile_group(context: click.Context, version: str | None) -> None:
"""Compile high level language smart contracts to TEAL"""
context.ensure_object(dict)
context.obj["version"] = version


@click.command(
context_settings={
"ignore_unknown_options": True,
},
add_help_option=False,
)
@click.argument("puya_args", nargs=-1, type=click.UNPROCESSED)
@click.pass_context
def compile_py_command(context: click.Context, puya_args: list[str]) -> None:
"""
Compile Python contract(s) to TEAL with PuyaPy
"""
version = str(context.obj["version"]) if context.obj["version"] else None

puya_command = find_valid_puyapy_command(version)

run_result = run(
[
*puya_command,
*puya_args,
],
)
click.echo(run_result.output)

if run_result.exit_code != 0:
click.secho(
"An error occurred during compile. Ensure supplied files are valid PuyaPy code before retrying.",
err=True,
fg="red",
)
raise click.exceptions.Exit(run_result.exit_code)


compile_group.add_command(compile_py_command, "python")
compile_group.add_command(compile_py_command, "py")
80 changes: 80 additions & 0 deletions src/algokit/core/compile/python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from collections.abc import Iterator

from algokit.core.proc import run
from algokit.core.utils import extract_version_triple, find_valid_pipx_command


def find_valid_puyapy_command(version: str | None) -> list[str]:
return _find_puya_command_at_version(version) if version is not None else _find_puya_command()


def _find_puya_command_at_version(version: str) -> list[str]:
"""
Find puya command with a specific version.
If the puya version isn't installed, install it with pipx run.
"""
for puyapy_command in _get_candidates_puyapy_commands():
try:
puyapy_version_result = run([*puyapy_command, "--version"])
except OSError:
pass # in case of path/permission issues, go to next candidate
else:
if puyapy_version_result.exit_code == 0 and (
extract_version_triple(version) == extract_version_triple(puyapy_version_result.output)
):
return puyapy_command

pipx_command = find_valid_pipx_command(
"Unable to find pipx install so that the `PuyaPy` compiler can be installed; "
"please install pipx via https://pypa.github.io/pipx/ "
"and then try `algokit compile py ...` again."
)

return [
*pipx_command,
"run",
f"puya=={version}",
]


def _find_puya_command() -> list[str]:
"""
Find puya command.
If puya isn't installed, install the latest version with pipx.
"""
for puyapy_command in _get_candidates_puyapy_commands():
try:
puyapy_help_result = run([*puyapy_command, "-h"])
except OSError:
pass # in case of path/permission issues, go to next candidate
else:
if puyapy_help_result.exit_code == 0:
return puyapy_command

pipx_command = find_valid_pipx_command(
"Unable to find pipx install so that the `PuyaPy` compiler can be installed; "
"please install pipx via https://pypa.github.io/pipx/ "
"and then try `algokit compile py ...` again."
)
_install_puyapy_with_pipx(pipx_command)
return ["puyapy"]


def _install_puyapy_with_pipx(pipx_command: list[str]) -> None:
run(
[
*pipx_command,
"install",
"puya",
],
bad_return_code_error_message=(
"Unable to install puya via pipx; please install puya manually and try `algokit compile py ...` again."
),
)


def _get_candidates_puyapy_commands() -> Iterator[list[str]]:
# when puya is installed at the project level
yield ["poetry", "run", "puyapy"]
# when puya is installed at the global level
yield ["puyapy"]
Empty file added tests/compile/__init__.py
Empty file.
27 changes: 27 additions & 0 deletions tests/compile/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
VALID_PUYA_CONTRACT_FILE_CONTENT = """
from puyapy import Contract, Txn, log
class HelloWorldContract(Contract):
def approval_program(self) -> bool:
name = Txn.application_args(0)
log(b"Hello, " + name)
return True
def clear_state_program(self) -> bool:
return True
"""

INVALID_PUYA_CONTRACT_FILE_CONTENT = """
from puyapy import Contract, Txn, log
class HelloWorldContract(Contract):
def approval_program(self) -> bool:
name = Txn.application_args_invalid(0)
log(b"Hello, " + name)
return True
def clear_state_program(self) -> bool:
return True
"""
136 changes: 136 additions & 0 deletions tests/compile/test_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import os
import sys
from pathlib import Path

import pytest
from pytest_mock import MockerFixture

from tests.compile.conftest import INVALID_PUYA_CONTRACT_FILE_CONTENT, VALID_PUYA_CONTRACT_FILE_CONTENT
from tests.utils.approvals import verify
from tests.utils.click_invoker import invoke
from tests.utils.proc_mock import ProcMock


def _normalize_path(path: Path) -> str:
return str(path.absolute()).replace("\\", r"\\")


@pytest.fixture()
def dummy_contract_path() -> Path:
return Path(__file__).parent / "dummy_contract.py"


@pytest.fixture(autouse=True)
def cwd(tmp_path_factory: pytest.TempPathFactory) -> Path:
return tmp_path_factory.mktemp("cwd", numbered=True)


@pytest.fixture()
def output_path(cwd: Path) -> Path:
return cwd / "output"


def test_compile_py_help(mocker: MockerFixture) -> None:
proc_mock = ProcMock()
proc_mock.set_output(["poetry", "run", "puyapy", "-h"], output=["Puyapy help"])

mocker.patch("algokit.core.proc.Popen").side_effect = proc_mock.popen
result = invoke("compile python -h")

assert result.exit_code == 0
verify(result.output)


def test_puyapy_is_not_installed_anywhere(dummy_contract_path: Path, mocker: MockerFixture) -> None:
proc_mock = ProcMock()
proc_mock.should_bad_exit_on(["poetry", "run", "puyapy", "-h"], exit_code=1, output=["Puyapy not found"])
proc_mock.should_bad_exit_on(["puyapy", "-h"], exit_code=1, output=["Puyapy not found"])

proc_mock.set_output(["pipx", "--version"], ["1.0.0"])

proc_mock.set_output(["pipx", "install", "puya"], ["Puyapy is installed"])
proc_mock.set_output(["puyapy", str(dummy_contract_path)], ["Done"])

mocker.patch("algokit.core.proc.Popen").side_effect = proc_mock.popen

result = invoke(f"compile python {_normalize_path(dummy_contract_path)}")

assert result.exit_code == 0
verify(result.output)


def test_specificed_puyapy_version_is_not_installed(dummy_contract_path: Path, mocker: MockerFixture) -> None:
current_version = "1.0.0"
target_version = "1.1.0"

proc_mock = ProcMock()
proc_mock.set_output(["poetry", "run", "puyapy", "--version"], output=[current_version])
proc_mock.should_bad_exit_on(["puyapy", "--version"], exit_code=1, output=["Puyapy not found"])

proc_mock.set_output(["pipx", "--version"], ["1.0.0"])
proc_mock.set_output(["pipx", "run", f"puya=={target_version}", str(dummy_contract_path)], ["Done"])

mocker.patch("algokit.core.proc.Popen").side_effect = proc_mock.popen

result = invoke(f"compile --version {target_version} py {_normalize_path(dummy_contract_path)}")

assert result.exit_code == 0
verify(result.output)


def test_puyapy_is_installed_in_project(dummy_contract_path: Path, mocker: MockerFixture) -> None:
proc_mock = ProcMock()
proc_mock.set_output(["poetry", "run", "puyapy", "-h"], output=["Puyapy help"])
proc_mock.set_output(["poetry", "run", "puyapy", str(dummy_contract_path)], ["Done"])

mocker.patch("algokit.core.proc.Popen").side_effect = proc_mock.popen

result = invoke(f"compile python {_normalize_path(dummy_contract_path)}")

assert result.exit_code == 0
verify(result.output)


def test_puyapy_is_installed_globally(dummy_contract_path: Path, mocker: MockerFixture) -> None:
proc_mock = ProcMock()
proc_mock.should_bad_exit_on(["poetry", "run", "puyapy", "-h"], exit_code=1, output=["Puyapy not found"])

proc_mock.set_output(["puyapy", "-h"], output=["Puyapy help"])
proc_mock.set_output(["puyapy", str(dummy_contract_path)], ["Done"])

mocker.patch("algokit.core.proc.Popen").side_effect = proc_mock.popen

result = invoke(f"compile python {_normalize_path(dummy_contract_path)}")

assert result.exit_code == 0
verify(result.output)


@pytest.mark.skipif(sys.version_info < (3, 12), reason="PuyaPy requires python3.12 or higher")
def test_valid_contract(cwd: Path, output_path: Path) -> None:
# Set NO_COLOR to 1 to avoid requirements for colorama on Windows
os.environ["NO_COLOR"] = "1"

contract_path = cwd / "contract.py"
contract_path.write_text(VALID_PUYA_CONTRACT_FILE_CONTENT)

result = invoke(f"compile python {_normalize_path(contract_path)} --out-dir {_normalize_path(output_path)}")

# Only check for the exit code, don't check the results from PuyaPy
assert result.exit_code == 0


@pytest.mark.skipif(sys.version_info < (3, 12), reason="PuyaPy requires python3.12 or higher")
def test_invalid_contract(cwd: Path, output_path: Path) -> None:
# Set NO_COLOR to 1 to avoid requirements for colorama on Windows
os.environ["NO_COLOR"] = "1"

contract_path = cwd / "contract.py"
contract_path.write_text(INVALID_PUYA_CONTRACT_FILE_CONTENT)
result = invoke(f"compile python {_normalize_path(contract_path)} --out-dir {_normalize_path(output_path)}")

# Only check for the exit code and the error message from AlgoKit CLI
assert result.exit_code == 1
result.output.endswith(
"An error occurred during compile. Ensure supplied files are valid PuyaPy code before retrying."
)
5 changes: 5 additions & 0 deletions tests/compile/test_python.test_compile_py_help.approved.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DEBUG: Running 'poetry run puyapy -h' in '{current_working_directory}'
DEBUG: poetry: Puyapy help
DEBUG: Running 'poetry run puyapy -h' in '{current_working_directory}'
DEBUG: poetry: Puyapy help
Puyapy help
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
DEBUG: Running 'poetry run puyapy -h' in '{current_working_directory}'
DEBUG: poetry: Puyapy not found
DEBUG: Running 'puyapy -h' in '{current_working_directory}'
DEBUG: puyapy: Puyapy help
DEBUG: Running 'puyapy {current_working_directory}/tests/compile/dummy_contract.py' in '{current_working_directory}'
DEBUG: puyapy: Done
Done
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
DEBUG: Running 'poetry run puyapy -h' in '{current_working_directory}'
DEBUG: poetry: Puyapy help
DEBUG: Running 'poetry run puyapy {current_working_directory}/tests/compile/dummy_contract.py' in '{current_working_directory}'
DEBUG: poetry: Done
Done
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
DEBUG: Running 'poetry run puyapy -h' in '{current_working_directory}'
DEBUG: poetry: Puyapy not found
DEBUG: Running 'puyapy -h' in '{current_working_directory}'
DEBUG: puyapy: Puyapy not found
DEBUG: Running 'pipx --version' in '{current_working_directory}'
DEBUG: pipx: 1.0.0
DEBUG: Running 'pipx install puya' in '{current_working_directory}'
DEBUG: pipx: Puyapy is installed
DEBUG: Running 'puyapy {current_working_directory}/tests/compile/dummy_contract.py' in '{current_working_directory}'
DEBUG: puyapy: Done
Done
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
DEBUG: Running 'poetry run puyapy --version' in '{current_working_directory}'
DEBUG: poetry: 1.0.0
DEBUG: Running 'puyapy --version' in '{current_working_directory}'
DEBUG: puyapy: Puyapy not found
DEBUG: Running 'pipx --version' in '{current_working_directory}'
DEBUG: pipx: 1.0.0
DEBUG: Running 'pipx run puya==1.1.0 {current_working_directory}/tests/compile/dummy_contract.py' in '{current_working_directory}'
DEBUG: pipx: Done
Done

1 comment on commit 1030799

@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
   completions.py108298%83, 98
   deploy.py72790%44, 46, 92–94, 158, 182
   dispenser.py121199%77
   doctor.py48394%142–144
   explore.py501276%34–39, 41–46
   generate.py67396%74–75, 140
   goal.py44198%71
   init.py2752591%384–385, 440, 443–445, 456, 460, 516, 542, 571, 604, 613–615, 618–623, 636, 653, 665–666, 683–686
   localnet.py1191587%74–78, 111, 123, 138–148, 161, 206, 227–228
   task.py34391%25–28
src/algokit/cli/common
   utils.py26292%120, 123
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.py66494%48, 70, 91, 250
   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.py994555%26–34, 40–43, 75–76, 100–101, 125–133, 152–162, 209, 258–259, 279–290, 297–299
   vanity_address.py561082%41, 45–48, 112, 114, 121–123
   wallet.py79495%21, 66, 136, 162
src/algokit/core
   bootstrap.py1191191%42, 106–107, 129, 156, 185–190
   conf.py661577%12, 24, 28, 36, 38, 72–74, 92–100
   deploy.py691184%61–64, 73–75, 79, 84, 91–93
   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.py48394%44, 81, 99
   goal.py60395%30–31, 41
   init.py39685%59, 63–68, 76
   log_handlers.py68790%50–51, 63, 112–116, 125
   proc.py45198%98
   sandbox.py2181892%62, 73–75, 96, 142–149, 160, 457, 473, 498, 506
   typed_client_generation.py80594%55–57, 70, 75
   utils.py1063171%44–45, 49–68, 129, 132, 138–152
   version_prompt.py921485%37–38, 68, 87–90, 108, 118–125, 148
src/algokit/core/compile
   python.py31584%19–20, 25, 48–49
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.py781087%123–133, 187
   models.py901188%50, 52, 57, 71–74, 85–88
TOTAL368843788% 

Tests Skipped Failures Errors Time
418 0 💤 0 ❌ 0 🔥 28.306s ⏱️

Please sign in to comment.