Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: handle client generation in a dir containing multiple app spec types #599

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 9 additions & 18 deletions src/algokit/cli/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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_failure=False,
)
except AppSpecsNotFoundError as ex:
raise click.ClickException("No app specs found") from ex
25 changes: 9 additions & 16 deletions src/algokit/cli/project/link.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import logging
import typing
from dataclasses import dataclass
from itertools import chain
from pathlib import Path

import click
Expand All @@ -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__)

Expand Down Expand Up @@ -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_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:
Expand Down
55 changes: 53 additions & 2 deletions src/algokit/core/typed_client_generation.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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: ...
Expand All @@ -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_failure: bool, # TODO: NC - Maybe we should return the error instead?
Copy link
Collaborator

Choose a reason for hiding this comment

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

Perhaps another option could be to remove raise_on_failure and add dataclass to represent result from generate_all response that will contain success and failure, something like

    class GenerateAllResult:
        successful_generations: list[tuple[Path, Path]]  # List of (app_spec, output_path) that succeeded
        failed_generations: list[tuple[Path, str]]  # List of (app_spec, error_message) that failed
        
        @property
        def has_failures(self) -> bool:
            """Returns True if any generations failed."""
            return len(self.failed_generations) > 0

and then the generate.py would be taking care of deciding when to raise an exception

) -> 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_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:
Expand Down
75 changes: 75 additions & 0 deletions tests/generate/app.arc32.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
95 changes: 95 additions & 0 deletions tests/generate/app.arc56.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
Loading
Loading