From 10307990a07fd3fa8ba60f6886f5b4be722dc065 Mon Sep 17 00:00:00 2001 From: Hoang Dinh Date: Tue, 12 Mar 2024 08:59:10 +1000 Subject: [PATCH] feat: add command to compile python to TEAL with Puyapy --- .github/workflows/build-python.yaml | 4 + src/algokit/cli/__init__.py | 2 + src/algokit/cli/compile.py | 67 +++++++++ src/algokit/core/compile/python.py | 80 +++++++++++ tests/compile/__init__.py | 0 tests/compile/conftest.py | 27 ++++ tests/compile/test_python.py | 136 ++++++++++++++++++ ...t_python.test_compile_py_help.approved.txt | 5 + ..._puyapy_is_installed_globally.approved.txt | 7 + ...uyapy_is_installed_in_project.approved.txt | 5 + ...apy_is_not_installed_anywhere.approved.txt | 11 ++ ...yapy_version_is_not_installed.approved.txt | 9 ++ 12 files changed, 353 insertions(+) create mode 100644 src/algokit/cli/compile.py create mode 100644 src/algokit/core/compile/python.py create mode 100644 tests/compile/__init__.py create mode 100644 tests/compile/conftest.py create mode 100644 tests/compile/test_python.py create mode 100644 tests/compile/test_python.test_compile_py_help.approved.txt create mode 100644 tests/compile/test_python.test_puyapy_is_installed_globally.approved.txt create mode 100644 tests/compile/test_python.test_puyapy_is_installed_in_project.approved.txt create mode 100644 tests/compile/test_python.test_puyapy_is_not_installed_anywhere.approved.txt create mode 100644 tests/compile/test_python.test_specificed_puyapy_version_is_not_installed.approved.txt diff --git a/.github/workflows/build-python.yaml b/.github/workflows/build-python.yaml index 73412f88..9774d2d6 100644 --- a/.github/workflows/build-python.yaml +++ b/.github/workflows/build-python.yaml @@ -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') }} diff --git a/src/algokit/cli/__init__.py b/src/algokit/cli/__init__.py index 2eaee190..f7e95d74 100644 --- a/src/algokit/cli/__init__.py +++ b/src/algokit/cli/__init__.py @@ -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 @@ -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) diff --git a/src/algokit/cli/compile.py b/src/algokit/cli/compile.py new file mode 100644 index 00000000..b5df7671 --- /dev/null +++ b/src/algokit/cli/compile.py @@ -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") diff --git a/src/algokit/core/compile/python.py b/src/algokit/core/compile/python.py new file mode 100644 index 00000000..e4e4b12c --- /dev/null +++ b/src/algokit/core/compile/python.py @@ -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"] diff --git a/tests/compile/__init__.py b/tests/compile/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/compile/conftest.py b/tests/compile/conftest.py new file mode 100644 index 00000000..44dfde6b --- /dev/null +++ b/tests/compile/conftest.py @@ -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 +""" diff --git a/tests/compile/test_python.py b/tests/compile/test_python.py new file mode 100644 index 00000000..2139ae16 --- /dev/null +++ b/tests/compile/test_python.py @@ -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." + ) diff --git a/tests/compile/test_python.test_compile_py_help.approved.txt b/tests/compile/test_python.test_compile_py_help.approved.txt new file mode 100644 index 00000000..17b77cdc --- /dev/null +++ b/tests/compile/test_python.test_compile_py_help.approved.txt @@ -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 diff --git a/tests/compile/test_python.test_puyapy_is_installed_globally.approved.txt b/tests/compile/test_python.test_puyapy_is_installed_globally.approved.txt new file mode 100644 index 00000000..ba256b52 --- /dev/null +++ b/tests/compile/test_python.test_puyapy_is_installed_globally.approved.txt @@ -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 diff --git a/tests/compile/test_python.test_puyapy_is_installed_in_project.approved.txt b/tests/compile/test_python.test_puyapy_is_installed_in_project.approved.txt new file mode 100644 index 00000000..c2963d12 --- /dev/null +++ b/tests/compile/test_python.test_puyapy_is_installed_in_project.approved.txt @@ -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 diff --git a/tests/compile/test_python.test_puyapy_is_not_installed_anywhere.approved.txt b/tests/compile/test_python.test_puyapy_is_not_installed_anywhere.approved.txt new file mode 100644 index 00000000..9eb04315 --- /dev/null +++ b/tests/compile/test_python.test_puyapy_is_not_installed_anywhere.approved.txt @@ -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 diff --git a/tests/compile/test_python.test_specificed_puyapy_version_is_not_installed.approved.txt b/tests/compile/test_python.test_specificed_puyapy_version_is_not_installed.approved.txt new file mode 100644 index 00000000..0cf292cd --- /dev/null +++ b/tests/compile/test_python.test_specificed_puyapy_version_is_not_installed.approved.txt @@ -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