diff --git a/poetry.lock b/poetry.lock index 1882af06..db01d73c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1066,13 +1066,13 @@ trio = ["async_generator", "trio"] [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.5" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.5-py3-none-any.whl", hash = "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"}, + {file = "jinja2-3.1.5.tar.gz", hash = "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb"}, ] [package.dependencies] diff --git a/src/algokit/cli/generate.py b/src/algokit/cli/generate.py index cb977ab3..f41992b2 100644 --- a/src/algokit/cli/generate.py +++ b/src/algokit/cli/generate.py @@ -6,7 +6,7 @@ import click from algokit.core.generate import load_generators, run_generator -from algokit.core.typed_client_generation import ClientGenerator +from algokit.core.typed_client_generation import AppSpecsNotFoundError, ClientGenerator logger = logging.getLogger(__name__) @@ -161,20 +161,11 @@ def generate_client( "One of --language or --output is required to determine the client language to generate" ) - if not app_spec_path_or_dir.is_dir(): - app_specs = [app_spec_path_or_dir] - else: - patterns = ["application.json", "*.arc32.json", "*.arc56.json"] - - app_specs = [] - for pattern in patterns: - app_specs.extend(app_spec_path_or_dir.rglob(pattern)) - - app_specs = list(set(app_specs)) - app_specs.sort() - if not app_specs: - raise click.ClickException("No app specs found") - for app_spec in app_specs: - output_path = generator.resolve_output_path(app_spec, output_path_pattern) - if output_path is not None: - generator.generate(app_spec, output_path) + try: + generator.generate_all( + app_spec_path_or_dir, + output_path_pattern, + raise_on_path_resolution_failure=False, + ) + except AppSpecsNotFoundError as ex: + raise click.ClickException("No app specs found") from ex diff --git a/src/algokit/cli/project/link.py b/src/algokit/cli/project/link.py index 7f28f122..fb6d08b6 100644 --- a/src/algokit/cli/project/link.py +++ b/src/algokit/cli/project/link.py @@ -1,7 +1,6 @@ import logging import typing from dataclasses import dataclass -from itertools import chain from pathlib import Path import click @@ -11,7 +10,7 @@ from algokit.core import questionary_extensions from algokit.core.conf import get_algokit_config from algokit.core.project import ProjectType, get_project_configs -from algokit.core.typed_client_generation import ClientGenerator +from algokit.core.typed_client_generation import AppSpecsNotFoundError, ClientGenerator logger = logging.getLogger(__name__) @@ -86,25 +85,19 @@ def _link_projects( """ output_path_pattern = f"{frontend_clients_path}/{{contract_name}}.{'ts' if language == 'typescript' else 'py'}" generator = ClientGenerator.create_for_language(language, version=version) - file_patterns = ["application.json", "*.arc32.json", "*.arc56.json"] - app_specs = list(chain.from_iterable(contract_project_root.rglob(pattern) for pattern in file_patterns)) - if not app_specs: + + try: + generator.generate_all( + contract_project_root, + output_path_pattern, + raise_on_path_resolution_failure=fail_fast, + ) + except AppSpecsNotFoundError: click.secho( f"WARNING: No application.json | *.arc32.json | *.arc56.json files found in {contract_project_root}. " "Skipping...", fg="yellow", ) - return - - for app_spec in app_specs: - output_path = generator.resolve_output_path(app_spec, output_path_pattern) - if output_path is None: - if fail_fast: - raise click.ClickException(f"Error generating client for {app_spec}") - - logger.warning(f"Error generating client for {app_spec}") - continue - generator.generate(app_spec, output_path) def _prompt_contract_project() -> ContractArtifacts | None: diff --git a/src/algokit/core/typed_client_generation.py b/src/algokit/core/typed_client_generation.py index 556145f2..3f6d0daf 100644 --- a/src/algokit/core/typed_client_generation.py +++ b/src/algokit/core/typed_client_generation.py @@ -1,8 +1,11 @@ import abc +import enum import json import logging import re import shutil # noqa: F401 +from functools import reduce +from itertools import chain from pathlib import Path from typing import ClassVar @@ -26,6 +29,15 @@ def _snake_case(s: str) -> str: return re.sub(r"[-\s]", "_", s).lower() +class AppSpecType(enum.Enum): + ARC32 = "arc32" + ARC56 = "arc56" + + +class AppSpecsNotFoundError(Exception): + pass + + class ClientGenerator(abc.ABC): language: ClassVar[str] extension: ClassVar[str] @@ -55,13 +67,15 @@ def create_for_language(cls, language: str, version: str | None) -> "ClientGener def create_for_extension(cls, extension: str, version: str | None) -> "ClientGenerator": return cls._by_extension[extension](version) - def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) -> Path | None: + def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) -> tuple[Path, AppSpecType] | None: try: application_json = json.loads(app_spec.read_text()) try: contract_name: str = application_json["name"] # ARC-56 + app_spec_type: AppSpecType = AppSpecType.ARC56 except KeyError: contract_name = application_json["contract"]["name"] # ARC-32 + app_spec_type = AppSpecType.ARC32 except Exception: logger.error(f"Couldn't parse contract name from {app_spec}", exc_info=True) return None @@ -73,7 +87,7 @@ def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) - if output_path.exists() and not output_path.is_file(): logger.error(f"Could not output to {output_path} as it already exists and is a directory") return None - return output_path + return (output_path, app_spec_type) @abc.abstractmethod def generate(self, app_spec: Path, output: Path) -> None: ... @@ -88,6 +102,43 @@ def find_generate_command(self, version: str | None) -> list[str]: ... def format_contract_name(self, contract_name: str) -> str: return contract_name + def generate_all( + self, + app_spec_path_or_dir: Path, + output_path_pattern: str | None, + *, + raise_on_path_resolution_failure: bool, + ) -> None: + if not app_spec_path_or_dir.is_dir(): + app_specs = [app_spec_path_or_dir] + else: + file_patterns = ["application.json", "*.arc32.json", "*.arc56.json"] + app_specs = list(set(chain.from_iterable(app_spec_path_or_dir.rglob(pattern) for pattern in file_patterns))) + app_specs.sort() + if not app_specs: + raise AppSpecsNotFoundError + + def accumulate_items_to_generate( + acc: dict[Path, tuple[Path, AppSpecType]], app_spec: Path + ) -> dict[Path, tuple[Path, AppSpecType]]: + output_path_result = self.resolve_output_path(app_spec, output_path_pattern) + if output_path_result is None: + if raise_on_path_resolution_failure: + raise click.ClickException(f"Error generating client for {app_spec}") + return acc + (output_path, app_spec_type) = output_path_result + if output_path in acc: + # ARC-56 app specs take precedence over ARC-32 app specs + if acc[output_path][1] == AppSpecType.ARC32 and app_spec_type == AppSpecType.ARC56: + acc[output_path] = (app_spec, app_spec_type) + else: + acc[output_path] = (app_spec, app_spec_type) + return acc + + items_to_generate: dict[Path, tuple[Path, AppSpecType]] = reduce(accumulate_items_to_generate, app_specs, {}) + for output_path, (app_spec, _) in items_to_generate.items(): + self.generate(app_spec, output_path) + class PythonClientGenerator(ClientGenerator, language="python", extension=".py"): def generate(self, app_spec: Path, output: Path) -> None: diff --git a/tests/generate/app.arc32.json b/tests/generate/app.arc32.json new file mode 100644 index 00000000..d4211398 --- /dev/null +++ b/tests/generate/app.arc32.json @@ -0,0 +1,75 @@ +{ + "hints": { + "hello(string)string": { + "call_config": { + "no_op": "CALL" + } + }, + "hello_world_check(string)void": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGJmOWMxZWRmIC8vICJoZWxsb193b3JsZF9jaGVjayhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDQKZXJyCm1haW5fbDQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBoZWxsb3dvcmxkY2hlY2tfMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGhlbGxvXzIKc3RvcmUgMApwdXNoYnl0ZXMgMHgxNTFmN2M3NSAvLyAweDE1MWY3Yzc1CmxvYWQgMApjb25jYXQKbG9nCmludGNfMSAvLyAxCnJldHVybgptYWluX2w2Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2wxMgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sMTEKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDEwCmVycgptYWluX2wxMDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTI6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZQp1cGRhdGVfMDoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX1VQREFUQUJMRSAvLyBUTVBMX1VQREFUQUJMRQovLyBDaGVjayBhcHAgaXMgdXBkYXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfMToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX0RFTEVUQUJMRSAvLyBUTVBMX0RFTEVUQUJMRQovLyBDaGVjayBhcHAgaXMgZGVsZXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGhlbGxvCmhlbGxvXzI6CnByb3RvIDEgMQpwdXNoYnl0ZXMgMHggLy8gIiIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjJjMjAgLy8gIkhlbGxvLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBoZWxsb193b3JsZF9jaGVjawpoZWxsb3dvcmxkY2hlY2tfMzoKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApwdXNoYnl0ZXMgMHg1NzZmNzI2YzY0IC8vICJXb3JsZCIKPT0KYXNzZXJ0CnJldHN1Yg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 + }, + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} + }, + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorldApp", + "methods": [ + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" + }, + "desc": "Returns Hello, {name}" + }, + { + "name": "hello_world_check", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "void" + }, + "desc": "Asserts {name} is \"World\"" + } + ], + "networks": {} + }, + "bare_call_config": { + "delete_application": "CALL", + "no_op": "CREATE", + "update_application": "CALL" + } +} \ No newline at end of file diff --git a/tests/generate/app.arc56.json b/tests/generate/app.arc56.json new file mode 100644 index 00000000..b85f68d9 --- /dev/null +++ b/tests/generate/app.arc56.json @@ -0,0 +1,95 @@ +{ + "name": "HelloWorldApp", + "structs": {}, + "methods": [ + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" + }, + "actions": { + "create": [], + "call": [ + "NoOp" + ] + }, + "readonly": false, + "events": [], + "recommendations": {} + } + ], + "arcs": [ + 22, + 28 + ], + "networks": {}, + "state": { + "schema": { + "global": { + "ints": 0, + "bytes": 0 + }, + "local": { + "ints": 0, + "bytes": 0 + } + }, + "keys": { + "global": {}, + "local": {}, + "box": {} + }, + "maps": { + "global": {}, + "local": {}, + "box": {} + } + }, + "bareActions": { + "create": [ + "NoOp" + ], + "call": [] + }, + "sourceInfo": { + "approval": { + "sourceInfo": [ + { + "pc": [ + 35 + ], + "errorMessage": "OnCompletion is not NoOp" + }, + { + "pc": [ + 75 + ], + "errorMessage": "can only call when creating" + }, + { + "pc": [ + 38 + ], + "errorMessage": "can only call when not creating" + } + ], + "pcOffsetMethod": "none" + }, + "clear": { + "sourceInfo": [], + "pcOffsetMethod": "none" + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5hcHByb3ZhbF9wcm9ncmFtOgogICAgaW50Y2Jsb2NrIDAgMQogICAgY2FsbHN1YiBfX3B1eWFfYXJjNF9yb3V0ZXJfXwogICAgcmV0dXJuCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkLmNvbnRyYWN0LkhlbGxvV29ybGQuX19wdXlhX2FyYzRfcm91dGVyX18oKSAtPiB1aW50NjQ6Cl9fcHV5YV9hcmM0X3JvdXRlcl9fOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHByb3RvIDAgMQogICAgdHhuIE51bUFwcEFyZ3MKICAgIGJ6IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1CiAgICBwdXNoYnl0ZXMgMHgwMmJlY2UxMSAvLyBtZXRob2QgImhlbGxvKHN0cmluZylzdHJpbmciCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAwCiAgICBtYXRjaCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyCiAgICBpbnRjXzAgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjYKICAgIC8vIEBhYmltZXRob2QoKQogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBub3QgTm9PcAogICAgdHhuIEFwcGxpY2F0aW9uSUQKICAgIGFzc2VydCAvLyBjYW4gb25seSBjYWxsIHdoZW4gbm90IGNyZWF0aW5nCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgZXh0cmFjdCAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZC9jb250cmFjdC5weTo2CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIGNhbGxzdWIgaGVsbG8KICAgIGR1cAogICAgbGVuCiAgICBpdG9iCiAgICBleHRyYWN0IDYgMgogICAgc3dhcAogICAgY29uY2F0CiAgICBwdXNoYnl0ZXMgMHgxNTFmN2M3NQogICAgc3dhcAogICAgY29uY2F0CiAgICBsb2cKICAgIGludGNfMSAvLyAxCiAgICByZXRzdWIKCl9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHR4biBPbkNvbXBsZXRpb24KICAgIGJueiBfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDkKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICAhCiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIGNyZWF0aW5nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDk6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgaW50Y18wIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZC5jb250cmFjdC5IZWxsb1dvcmxkLmhlbGxvKG5hbWU6IGJ5dGVzKSAtPiBieXRlczoKaGVsbG86CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6Ni03CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBoZWxsbyhzZWxmLCBuYW1lOiBTdHJpbmcpIC0+IFN0cmluZzoKICAgIHByb3RvIDEgMQogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjgKICAgIC8vIHJldHVybiAiSGVsbG8sICIgKyBuYW1lCiAgICBwdXNoYnl0ZXMgIkhlbGxvLCAiCiAgICBmcmFtZV9kaWcgLTEKICAgIGNvbmNhdAogICAgcmV0c3ViCg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5jbGVhcl9zdGF0ZV9wcm9ncmFtOgogICAgcHVzaGludCAxIC8vIDEKICAgIHJldHVybgo=" + }, + "events": [], + "templateVariables": {} +} \ No newline at end of file diff --git a/tests/generate/application.json b/tests/generate/application.json index 1c0952e7..d4211398 100644 --- a/tests/generate/application.json +++ b/tests/generate/application.json @@ -1,75 +1,75 @@ { - "hints": { - "hello(string)string": { - "call_config": { - "no_op": "CALL" - } - }, - "hello_world_check(string)void": { - "call_config": { - "no_op": "CALL" - } - } + "hints": { + "hello(string)string": { + "call_config": { + "no_op": "CALL" + } }, - "source": { - "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGJmOWMxZWRmIC8vICJoZWxsb193b3JsZF9jaGVjayhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDQKZXJyCm1haW5fbDQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBoZWxsb3dvcmxkY2hlY2tfMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGhlbGxvXzIKc3RvcmUgMApwdXNoYnl0ZXMgMHgxNTFmN2M3NSAvLyAweDE1MWY3Yzc1CmxvYWQgMApjb25jYXQKbG9nCmludGNfMSAvLyAxCnJldHVybgptYWluX2w2Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2wxMgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sMTEKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDEwCmVycgptYWluX2wxMDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTI6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZQp1cGRhdGVfMDoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX1VQREFUQUJMRSAvLyBUTVBMX1VQREFUQUJMRQovLyBDaGVjayBhcHAgaXMgdXBkYXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfMToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX0RFTEVUQUJMRSAvLyBUTVBMX0RFTEVUQUJMRQovLyBDaGVjayBhcHAgaXMgZGVsZXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGhlbGxvCmhlbGxvXzI6CnByb3RvIDEgMQpwdXNoYnl0ZXMgMHggLy8gIiIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjJjMjAgLy8gIkhlbGxvLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBoZWxsb193b3JsZF9jaGVjawpoZWxsb3dvcmxkY2hlY2tfMzoKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApwdXNoYnl0ZXMgMHg1NzZmNzI2YzY0IC8vICJXb3JsZCIKPT0KYXNzZXJ0CnJldHN1Yg==", - "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + "hello_world_check(string)void": { + "call_config": { + "no_op": "CALL" + } + } + }, + "source": { + "approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGJmOWMxZWRmIC8vICJoZWxsb193b3JsZF9jaGVjayhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDQKZXJyCm1haW5fbDQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBoZWxsb3dvcmxkY2hlY2tfMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGhlbGxvXzIKc3RvcmUgMApwdXNoYnl0ZXMgMHgxNTFmN2M3NSAvLyAweDE1MWY3Yzc1CmxvYWQgMApjb25jYXQKbG9nCmludGNfMSAvLyAxCnJldHVybgptYWluX2w2Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2wxMgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sMTEKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDEwCmVycgptYWluX2wxMDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTI6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZQp1cGRhdGVfMDoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX1VQREFUQUJMRSAvLyBUTVBMX1VQREFUQUJMRQovLyBDaGVjayBhcHAgaXMgdXBkYXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfMToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX0RFTEVUQUJMRSAvLyBUTVBMX0RFTEVUQUJMRQovLyBDaGVjayBhcHAgaXMgZGVsZXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGhlbGxvCmhlbGxvXzI6CnByb3RvIDEgMQpwdXNoYnl0ZXMgMHggLy8gIiIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjJjMjAgLy8gIkhlbGxvLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBoZWxsb193b3JsZF9jaGVjawpoZWxsb3dvcmxkY2hlY2tfMzoKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApwdXNoYnl0ZXMgMHg1NzZmNzI2YzY0IC8vICJXb3JsZCIKPT0KYXNzZXJ0CnJldHN1Yg==", + "clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu" + }, + "state": { + "global": { + "num_byte_slices": 0, + "num_uints": 0 }, - "state": { - "global": { - "num_byte_slices": 0, - "num_uints": 0 - }, - "local": { - "num_byte_slices": 0, - "num_uints": 0 - } + "local": { + "num_byte_slices": 0, + "num_uints": 0 + } + }, + "schema": { + "global": { + "declared": {}, + "reserved": {} }, - "schema": { - "global": { - "declared": {}, - "reserved": {} + "local": { + "declared": {}, + "reserved": {} + } + }, + "contract": { + "name": "HelloWorldApp", + "methods": [ + { + "name": "hello", + "args": [ + { + "type": "string", + "name": "name" + } + ], + "returns": { + "type": "string" }, - "local": { - "declared": {}, - "reserved": {} - } - }, - "contract": { - "name": "HelloWorldApp", - "methods": [ - { - "name": "hello", - "args": [ - { - "type": "string", - "name": "name" - } - ], - "returns": { - "type": "string" - }, - "desc": "Returns Hello, {name}" - }, - { - "name": "hello_world_check", - "args": [ - { - "type": "string", - "name": "name" - } - ], - "returns": { - "type": "void" - }, - "desc": "Asserts {name} is \"World\"" - } + "desc": "Returns Hello, {name}" + }, + { + "name": "hello_world_check", + "args": [ + { + "type": "string", + "name": "name" + } ], - "networks": {} - }, - "bare_call_config": { - "delete_application": "CALL", - "no_op": "CREATE", - "update_application": "CALL" - } + "returns": { + "type": "void" + }, + "desc": "Asserts {name} is \"World\"" + } + ], + "networks": {} + }, + "bare_call_config": { + "delete_application": "CALL", + "no_op": "CREATE", + "update_application": "CALL" + } } \ No newline at end of file diff --git a/tests/generate/test_generate_client.py b/tests/generate/test_generate_client.py index ee5cdbae..2f972af1 100644 --- a/tests/generate/test_generate_client.py +++ b/tests/generate/test_generate_client.py @@ -55,9 +55,8 @@ def cwd(tmp_path_factory: TempPathFactory) -> Path: @pytest.fixture() def dir_with_app_spec_factory() -> DirWithAppSpecFactory: - app_spec_example_path = Path(__file__).parent / "application.json" - def factory(app_spec_dir: Path, app_spec_file_name: str) -> Path: + app_spec_example_path = Path(__file__).parent / app_spec_file_name app_spec_dir.mkdir(exist_ok=True, parents=True) app_spec_path = app_spec_dir / app_spec_file_name shutil.copy(app_spec_example_path, app_spec_path) @@ -223,7 +222,10 @@ def test_generate_client_python_arc32_filename( ], ) def test_generate_client_python_arc56_filename( - proc_mock: ProcMock, arc56_json: Path, options: str, expected_output_path: Path + proc_mock: ProcMock, + arc56_json: Path, + options: str, + expected_output_path: Path, ) -> None: proc_mock.should_bad_exit_on(["poetry", "show", PYTHON_PYPI_PACKAGE, "--tree"]) proc_mock.should_bad_exit_on(["pipx", "list", "--short"]) @@ -236,6 +238,36 @@ def test_generate_client_python_arc56_filename( assert proc_mock.called[3].command == _get_python_generate_command(None, arc56_json, expected_output_path).split() +@pytest.mark.parametrize( + ("options", "expected_output_path"), + [ + ("-o client.py", "client.py"), + ], +) +def test_generate_client_python_multiple_app_specs_in_directory( + proc_mock: ProcMock, + arc56_json: Path, + arc32_json: Path, + application_json: Path, + options: str, + expected_output_path: Path, +) -> None: + proc_mock.should_bad_exit_on(["poetry", "show", PYTHON_PYPI_PACKAGE, "--tree"]) + proc_mock.should_bad_exit_on(["pipx", "list", "--short"]) + + result = invoke(f"generate client {options} .", cwd=arc56_json.parent) + + # Confirm multiple app specs are in the input directory + assert arc32_json.parent == arc56_json.parent + assert application_json.parent == arc56_json.parent + + assert result.exit_code == 0 + verify(_normalize_output(result.output), options=NamerFactory.with_parameters(*options.split())) + # only a single generate call is made for the arc56 app spec + assert len(proc_mock.called) == 4 # noqa: PLR2004 + assert proc_mock.called[3].command == _get_python_generate_command(None, arc56_json, expected_output_path).split() + + @pytest.mark.usefixtures("mock_platform_system") @pytest.mark.parametrize( ("options", "expected_output_path"), diff --git a/tests/generate/test_generate_client.test_generate_client_python_multiple_app_specs_in_directory.-o.client.py.approved.txt b/tests/generate/test_generate_client.test_generate_client_python_multiple_app_specs_in_directory.-o.client.py.approved.txt new file mode 100644 index 00000000..0025cbc8 --- /dev/null +++ b/tests/generate/test_generate_client.test_generate_client_python_multiple_app_specs_in_directory.-o.client.py.approved.txt @@ -0,0 +1,18 @@ +DEBUG: Searching for project installed client generator +DEBUG: Running 'poetry show algokit-client-generator --tree' in '{current_working_directory}' +DEBUG: poetry: STDOUT +DEBUG: poetry: STDERR +DEBUG: Running 'pipx --version' in '{current_working_directory}' +DEBUG: pipx: STDOUT +DEBUG: pipx: STDERR +DEBUG: Searching for globally installed client generator +DEBUG: Running 'pipx list --short' in '{current_working_directory}' +DEBUG: pipx: STDOUT +DEBUG: pipx: STDERR +DEBUG: No matching installed client generator found, run client generator via pipx +Generating Python client code for application specified in {current_working_directory}/app.arc56.json and writing to client.py +DEBUG: Running 'pipx run --spec=algokit-client-generator algokitgen-py -a {current_working_directory}/app.arc56.json -o client.py' in '{current_working_directory}' +DEBUG: pipx: STDOUT +DEBUG: pipx: STDERR +STDOUT +STDERR diff --git a/tests/project/link/test_link.py b/tests/project/link/test_link.py index df515137..49261715 100644 --- a/tests/project/link/test_link.py +++ b/tests/project/link/test_link.py @@ -1,9 +1,10 @@ import shutil from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import pytest from _pytest.tmpdir import TempPathFactory +from algokit.core.typed_client_generation import AppSpecsNotFoundError from pytest_mock import MockerFixture from tests.utils.approvals import verify @@ -31,6 +32,7 @@ def client_generator_mock(mocker: MockerFixture) -> MagicMock: client_gen_mock = MagicMock() mocker.patch("src.algokit.cli.generate.ClientGenerator.create_for_language", return_value=client_gen_mock) + client_gen_mock.generate_all.return_value = None return client_gen_mock @@ -161,14 +163,13 @@ def test_link_command_by_name_success( tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock, client_generator_mock: MagicMock ) -> None: """ - Verifies 'project list' command success for a specific project name. + Verifies 'project link' command success for a specific project name. """ cwd_with_workspace = _cwd_with_workspace(tmp_path_factory, which_mock, proc_mock, num_projects=5) result = invoke("project link --project-name contract_project_3", cwd=cwd_with_workspace / "projects" / "project1") assert result.exit_code == 0 - client_generator_mock.resolve_output_path.assert_called_once() - client_generator_mock.generate.assert_called_once() + client_generator_mock.generate_all.assert_called_once() verify(_format_output(result.output, [(str(cwd_with_workspace), "")])) @@ -176,7 +177,7 @@ def test_link_command_all_success( tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock, client_generator_mock: MagicMock ) -> None: """ - Confirms 'project list' command lists all projects successfully. + Confirms 'project link' command links all projects successfully. """ contract_projects_count = 4 frontend_projects_count = 1 @@ -186,8 +187,8 @@ def test_link_command_all_success( result = invoke("project link --all", cwd=cwd_with_workspace / "projects" / "project1") assert result.exit_code == 0 - assert client_generator_mock.resolve_output_path.call_count == contract_projects_count - assert client_generator_mock.generate.call_count == contract_projects_count + assert client_generator_mock.generate_all.call_count == contract_projects_count + verify(_format_output(result.output, [(str(cwd_with_workspace), "")])) @@ -195,7 +196,7 @@ def test_link_command_multiple_names_success( tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock, client_generator_mock: MagicMock ) -> None: """ - Ensures 'project list' command success for multiple specified project names. + Ensures 'project link' command success for multiple specified project names. """ projects_count = 5 cwd_with_workspace = _cwd_with_workspace(tmp_path_factory, which_mock, proc_mock, num_projects=projects_count) @@ -207,8 +208,7 @@ def test_link_command_multiple_names_success( assert result.exit_code == 0 expected_call_count = 2 - assert client_generator_mock.resolve_output_path.call_count == expected_call_count - assert client_generator_mock.generate.call_count == expected_call_count + assert client_generator_mock.generate_all.call_count == expected_call_count verify(_format_output(result.output, [(str(cwd_with_workspace), "")])) @@ -216,19 +216,21 @@ def test_link_command_multiple_names_no_specs_success( tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock, client_generator_mock: MagicMock ) -> None: """ - Ensures 'project list' command success for multiple specified project names. + Ensures 'project link' command success for multiple specified project names. """ cwd_with_workspace = _cwd_with_workspace( tmp_path_factory, which_mock, proc_mock, num_projects=5, with_app_spec=False ) + client_generator_mock.generate_all.side_effect = Mock(side_effect=AppSpecsNotFoundError()) + result = invoke( "project link --project-name contract_project_3 --project-name contract_project_5", cwd=cwd_with_workspace / "projects" / "project1", ) assert result.exit_code == 0 - assert client_generator_mock.resolve_output_path.call_count == 0 - assert client_generator_mock.generate.call_count == 0 + assert client_generator_mock.generate_all.call_count == 2 # noqa: PLR2004 + verify(_format_output(result.output, [(str(cwd_with_workspace), "")])) @@ -238,15 +240,7 @@ def test_link_command_name_not_found( proc_mock: ProcMock, ) -> None: """ - Test to ensure the 'project list' command executes successfully within a workspace containing multiple projects. - - This test simulates a workspace environment with 20 projects and verifies that the - command lists all projects without errors. - - Args: - tmp_path_factory (TempPathFactory): A fixture to create temporary directories. - which_mock (WhichMock): A mock for the 'which' command. - proc_mock (ProcMock): A mock for process execution. + Ensures 'project link' command success for project that does not exist. """ cwd_with_workspace = _cwd_with_workspace(tmp_path_factory, which_mock, proc_mock, num_projects=5) result = invoke( @@ -262,15 +256,7 @@ def test_link_command_empty_folder( tmp_path_factory: TempPathFactory, ) -> None: """ - Test to ensure the 'project list' command executes successfully within a workspace containing multiple projects. - - This test simulates a workspace environment with 20 projects and verifies that the - command lists all projects without errors. - - Args: - tmp_path_factory (TempPathFactory): A fixture to create temporary directories. - which_mock (WhichMock): A mock for the 'which' command. - proc_mock (ProcMock): A mock for process execution. + Ensures 'project link' command success for empty folder. """ cwd = tmp_path_factory.mktemp("cwd") result = invoke("project link --all", cwd=cwd)