Skip to content

Commit 1a479a1

Browse files
authored
fix: handle client generation in a dir containing multiple app spec types (#599)
* fix: handle client generation in a dir containing multiple app spec types
1 parent 1e0e39c commit 1a479a1

10 files changed

Lines changed: 382 additions & 141 deletions

poetry.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/algokit/cli/generate.py

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import click
77

88
from algokit.core.generate import load_generators, run_generator
9-
from algokit.core.typed_client_generation import ClientGenerator
9+
from algokit.core.typed_client_generation import AppSpecsNotFoundError, ClientGenerator
1010

1111
logger = logging.getLogger(__name__)
1212

@@ -161,20 +161,11 @@ def generate_client(
161161
"One of --language or --output is required to determine the client language to generate"
162162
)
163163

164-
if not app_spec_path_or_dir.is_dir():
165-
app_specs = [app_spec_path_or_dir]
166-
else:
167-
patterns = ["application.json", "*.arc32.json", "*.arc56.json"]
168-
169-
app_specs = []
170-
for pattern in patterns:
171-
app_specs.extend(app_spec_path_or_dir.rglob(pattern))
172-
173-
app_specs = list(set(app_specs))
174-
app_specs.sort()
175-
if not app_specs:
176-
raise click.ClickException("No app specs found")
177-
for app_spec in app_specs:
178-
output_path = generator.resolve_output_path(app_spec, output_path_pattern)
179-
if output_path is not None:
180-
generator.generate(app_spec, output_path)
164+
try:
165+
generator.generate_all(
166+
app_spec_path_or_dir,
167+
output_path_pattern,
168+
raise_on_path_resolution_failure=False,
169+
)
170+
except AppSpecsNotFoundError as ex:
171+
raise click.ClickException("No app specs found") from ex

src/algokit/cli/project/link.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import logging
22
import typing
33
from dataclasses import dataclass
4-
from itertools import chain
54
from pathlib import Path
65

76
import click
@@ -11,7 +10,7 @@
1110
from algokit.core import questionary_extensions
1211
from algokit.core.conf import get_algokit_config
1312
from algokit.core.project import ProjectType, get_project_configs
14-
from algokit.core.typed_client_generation import ClientGenerator
13+
from algokit.core.typed_client_generation import AppSpecsNotFoundError, ClientGenerator
1514

1615
logger = logging.getLogger(__name__)
1716

@@ -86,25 +85,19 @@ def _link_projects(
8685
"""
8786
output_path_pattern = f"{frontend_clients_path}/{{contract_name}}.{'ts' if language == 'typescript' else 'py'}"
8887
generator = ClientGenerator.create_for_language(language, version=version)
89-
file_patterns = ["application.json", "*.arc32.json", "*.arc56.json"]
90-
app_specs = list(chain.from_iterable(contract_project_root.rglob(pattern) for pattern in file_patterns))
91-
if not app_specs:
88+
89+
try:
90+
generator.generate_all(
91+
contract_project_root,
92+
output_path_pattern,
93+
raise_on_path_resolution_failure=fail_fast,
94+
)
95+
except AppSpecsNotFoundError:
9296
click.secho(
9397
f"WARNING: No application.json | *.arc32.json | *.arc56.json files found in {contract_project_root}. "
9498
"Skipping...",
9599
fg="yellow",
96100
)
97-
return
98-
99-
for app_spec in app_specs:
100-
output_path = generator.resolve_output_path(app_spec, output_path_pattern)
101-
if output_path is None:
102-
if fail_fast:
103-
raise click.ClickException(f"Error generating client for {app_spec}")
104-
105-
logger.warning(f"Error generating client for {app_spec}")
106-
continue
107-
generator.generate(app_spec, output_path)
108101

109102

110103
def _prompt_contract_project() -> ContractArtifacts | None:

src/algokit/core/typed_client_generation.py

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import abc
2+
import enum
23
import json
34
import logging
45
import re
56
import shutil # noqa: F401
7+
from functools import reduce
8+
from itertools import chain
69
from pathlib import Path
710
from typing import ClassVar
811

@@ -26,6 +29,15 @@ def _snake_case(s: str) -> str:
2629
return re.sub(r"[-\s]", "_", s).lower()
2730

2831

32+
class AppSpecType(enum.Enum):
33+
ARC32 = "arc32"
34+
ARC56 = "arc56"
35+
36+
37+
class AppSpecsNotFoundError(Exception):
38+
pass
39+
40+
2941
class ClientGenerator(abc.ABC):
3042
language: ClassVar[str]
3143
extension: ClassVar[str]
@@ -55,13 +67,15 @@ def create_for_language(cls, language: str, version: str | None) -> "ClientGener
5567
def create_for_extension(cls, extension: str, version: str | None) -> "ClientGenerator":
5668
return cls._by_extension[extension](version)
5769

58-
def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) -> Path | None:
70+
def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) -> tuple[Path, AppSpecType] | None:
5971
try:
6072
application_json = json.loads(app_spec.read_text())
6173
try:
6274
contract_name: str = application_json["name"] # ARC-56
75+
app_spec_type: AppSpecType = AppSpecType.ARC56
6376
except KeyError:
6477
contract_name = application_json["contract"]["name"] # ARC-32
78+
app_spec_type = AppSpecType.ARC32
6579
except Exception:
6680
logger.error(f"Couldn't parse contract name from {app_spec}", exc_info=True)
6781
return None
@@ -73,7 +87,7 @@ def resolve_output_path(self, app_spec: Path, output_path_pattern: str | None) -
7387
if output_path.exists() and not output_path.is_file():
7488
logger.error(f"Could not output to {output_path} as it already exists and is a directory")
7589
return None
76-
return output_path
90+
return (output_path, app_spec_type)
7791

7892
@abc.abstractmethod
7993
def generate(self, app_spec: Path, output: Path) -> None: ...
@@ -88,6 +102,43 @@ def find_generate_command(self, version: str | None) -> list[str]: ...
88102
def format_contract_name(self, contract_name: str) -> str:
89103
return contract_name
90104

105+
def generate_all(
106+
self,
107+
app_spec_path_or_dir: Path,
108+
output_path_pattern: str | None,
109+
*,
110+
raise_on_path_resolution_failure: bool,
111+
) -> None:
112+
if not app_spec_path_or_dir.is_dir():
113+
app_specs = [app_spec_path_or_dir]
114+
else:
115+
file_patterns = ["application.json", "*.arc32.json", "*.arc56.json"]
116+
app_specs = list(set(chain.from_iterable(app_spec_path_or_dir.rglob(pattern) for pattern in file_patterns)))
117+
app_specs.sort()
118+
if not app_specs:
119+
raise AppSpecsNotFoundError
120+
121+
def accumulate_items_to_generate(
122+
acc: dict[Path, tuple[Path, AppSpecType]], app_spec: Path
123+
) -> dict[Path, tuple[Path, AppSpecType]]:
124+
output_path_result = self.resolve_output_path(app_spec, output_path_pattern)
125+
if output_path_result is None:
126+
if raise_on_path_resolution_failure:
127+
raise click.ClickException(f"Error generating client for {app_spec}")
128+
return acc
129+
(output_path, app_spec_type) = output_path_result
130+
if output_path in acc:
131+
# ARC-56 app specs take precedence over ARC-32 app specs
132+
if acc[output_path][1] == AppSpecType.ARC32 and app_spec_type == AppSpecType.ARC56:
133+
acc[output_path] = (app_spec, app_spec_type)
134+
else:
135+
acc[output_path] = (app_spec, app_spec_type)
136+
return acc
137+
138+
items_to_generate: dict[Path, tuple[Path, AppSpecType]] = reduce(accumulate_items_to_generate, app_specs, {})
139+
for output_path, (app_spec, _) in items_to_generate.items():
140+
self.generate(app_spec, output_path)
141+
91142

92143
class PythonClientGenerator(ClientGenerator, language="python", extension=".py"):
93144
def generate(self, app_spec: Path, output: Path) -> None:

tests/generate/app.arc32.json

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
{
2+
"hints": {
3+
"hello(string)string": {
4+
"call_config": {
5+
"no_op": "CALL"
6+
}
7+
},
8+
"hello_world_check(string)void": {
9+
"call_config": {
10+
"no_op": "CALL"
11+
}
12+
}
13+
},
14+
"source": {
15+
"approval": "I3ByYWdtYSB2ZXJzaW9uIDgKaW50Y2Jsb2NrIDAgMQp0eG4gTnVtQXBwQXJncwppbnRjXzAgLy8gMAo9PQpibnogbWFpbl9sNgp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweDAyYmVjZTExIC8vICJoZWxsbyhzdHJpbmcpc3RyaW5nIgo9PQpibnogbWFpbl9sNQp0eG5hIEFwcGxpY2F0aW9uQXJncyAwCnB1c2hieXRlcyAweGJmOWMxZWRmIC8vICJoZWxsb193b3JsZF9jaGVjayhzdHJpbmcpdm9pZCIKPT0KYm56IG1haW5fbDQKZXJyCm1haW5fbDQ6CnR4biBPbkNvbXBsZXRpb24KaW50Y18wIC8vIE5vT3AKPT0KdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KJiYKYXNzZXJ0CnR4bmEgQXBwbGljYXRpb25BcmdzIDEKY2FsbHN1YiBoZWxsb3dvcmxkY2hlY2tfMwppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sNToKdHhuIE9uQ29tcGxldGlvbgppbnRjXzAgLy8gTm9PcAo9PQp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQomJgphc3NlcnQKdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQpjYWxsc3ViIGhlbGxvXzIKc3RvcmUgMApwdXNoYnl0ZXMgMHgxNTFmN2M3NSAvLyAweDE1MWY3Yzc1CmxvYWQgMApjb25jYXQKbG9nCmludGNfMSAvLyAxCnJldHVybgptYWluX2w2Ogp0eG4gT25Db21wbGV0aW9uCmludGNfMCAvLyBOb09wCj09CmJueiBtYWluX2wxMgp0eG4gT25Db21wbGV0aW9uCnB1c2hpbnQgNCAvLyBVcGRhdGVBcHBsaWNhdGlvbgo9PQpibnogbWFpbl9sMTEKdHhuIE9uQ29tcGxldGlvbgpwdXNoaW50IDUgLy8gRGVsZXRlQXBwbGljYXRpb24KPT0KYm56IG1haW5fbDEwCmVycgptYWluX2wxMDoKdHhuIEFwcGxpY2F0aW9uSUQKaW50Y18wIC8vIDAKIT0KYXNzZXJ0CmNhbGxzdWIgZGVsZXRlXzEKaW50Y18xIC8vIDEKcmV0dXJuCm1haW5fbDExOgp0eG4gQXBwbGljYXRpb25JRAppbnRjXzAgLy8gMAohPQphc3NlcnQKY2FsbHN1YiB1cGRhdGVfMAppbnRjXzEgLy8gMQpyZXR1cm4KbWFpbl9sMTI6CnR4biBBcHBsaWNhdGlvbklECmludGNfMCAvLyAwCj09CmFzc2VydAppbnRjXzEgLy8gMQpyZXR1cm4KCi8vIHVwZGF0ZQp1cGRhdGVfMDoKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX1VQREFUQUJMRSAvLyBUTVBMX1VQREFUQUJMRQovLyBDaGVjayBhcHAgaXMgdXBkYXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGRlbGV0ZQpkZWxldGVfMToKcHJvdG8gMCAwCnR4biBTZW5kZXIKZ2xvYmFsIENyZWF0b3JBZGRyZXNzCj09Ci8vIHVuYXV0aG9yaXplZAphc3NlcnQKcHVzaGludCBUTVBMX0RFTEVUQUJMRSAvLyBUTVBMX0RFTEVUQUJMRQovLyBDaGVjayBhcHAgaXMgZGVsZXRhYmxlCmFzc2VydApyZXRzdWIKCi8vIGhlbGxvCmhlbGxvXzI6CnByb3RvIDEgMQpwdXNoYnl0ZXMgMHggLy8gIiIKcHVzaGJ5dGVzIDB4NDg2NTZjNmM2ZjJjMjAgLy8gIkhlbGxvLCAiCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApjb25jYXQKZnJhbWVfYnVyeSAwCmZyYW1lX2RpZyAwCmxlbgppdG9iCmV4dHJhY3QgNiAwCmZyYW1lX2RpZyAwCmNvbmNhdApmcmFtZV9idXJ5IDAKcmV0c3ViCgovLyBoZWxsb193b3JsZF9jaGVjawpoZWxsb3dvcmxkY2hlY2tfMzoKcHJvdG8gMSAwCmZyYW1lX2RpZyAtMQpleHRyYWN0IDIgMApwdXNoYnl0ZXMgMHg1NzZmNzI2YzY0IC8vICJXb3JsZCIKPT0KYXNzZXJ0CnJldHN1Yg==",
16+
"clear": "I3ByYWdtYSB2ZXJzaW9uIDgKcHVzaGludCAwIC8vIDAKcmV0dXJu"
17+
},
18+
"state": {
19+
"global": {
20+
"num_byte_slices": 0,
21+
"num_uints": 0
22+
},
23+
"local": {
24+
"num_byte_slices": 0,
25+
"num_uints": 0
26+
}
27+
},
28+
"schema": {
29+
"global": {
30+
"declared": {},
31+
"reserved": {}
32+
},
33+
"local": {
34+
"declared": {},
35+
"reserved": {}
36+
}
37+
},
38+
"contract": {
39+
"name": "HelloWorldApp",
40+
"methods": [
41+
{
42+
"name": "hello",
43+
"args": [
44+
{
45+
"type": "string",
46+
"name": "name"
47+
}
48+
],
49+
"returns": {
50+
"type": "string"
51+
},
52+
"desc": "Returns Hello, {name}"
53+
},
54+
{
55+
"name": "hello_world_check",
56+
"args": [
57+
{
58+
"type": "string",
59+
"name": "name"
60+
}
61+
],
62+
"returns": {
63+
"type": "void"
64+
},
65+
"desc": "Asserts {name} is \"World\""
66+
}
67+
],
68+
"networks": {}
69+
},
70+
"bare_call_config": {
71+
"delete_application": "CALL",
72+
"no_op": "CREATE",
73+
"update_application": "CALL"
74+
}
75+
}

tests/generate/app.arc56.json

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
{
2+
"name": "HelloWorldApp",
3+
"structs": {},
4+
"methods": [
5+
{
6+
"name": "hello",
7+
"args": [
8+
{
9+
"type": "string",
10+
"name": "name"
11+
}
12+
],
13+
"returns": {
14+
"type": "string"
15+
},
16+
"actions": {
17+
"create": [],
18+
"call": [
19+
"NoOp"
20+
]
21+
},
22+
"readonly": false,
23+
"events": [],
24+
"recommendations": {}
25+
}
26+
],
27+
"arcs": [
28+
22,
29+
28
30+
],
31+
"networks": {},
32+
"state": {
33+
"schema": {
34+
"global": {
35+
"ints": 0,
36+
"bytes": 0
37+
},
38+
"local": {
39+
"ints": 0,
40+
"bytes": 0
41+
}
42+
},
43+
"keys": {
44+
"global": {},
45+
"local": {},
46+
"box": {}
47+
},
48+
"maps": {
49+
"global": {},
50+
"local": {},
51+
"box": {}
52+
}
53+
},
54+
"bareActions": {
55+
"create": [
56+
"NoOp"
57+
],
58+
"call": []
59+
},
60+
"sourceInfo": {
61+
"approval": {
62+
"sourceInfo": [
63+
{
64+
"pc": [
65+
35
66+
],
67+
"errorMessage": "OnCompletion is not NoOp"
68+
},
69+
{
70+
"pc": [
71+
75
72+
],
73+
"errorMessage": "can only call when creating"
74+
},
75+
{
76+
"pc": [
77+
38
78+
],
79+
"errorMessage": "can only call when not creating"
80+
}
81+
],
82+
"pcOffsetMethod": "none"
83+
},
84+
"clear": {
85+
"sourceInfo": [],
86+
"pcOffsetMethod": "none"
87+
}
88+
},
89+
"source": {
90+
"approval": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5hcHByb3ZhbF9wcm9ncmFtOgogICAgaW50Y2Jsb2NrIDAgMQogICAgY2FsbHN1YiBfX3B1eWFfYXJjNF9yb3V0ZXJfXwogICAgcmV0dXJuCgoKLy8gc21hcnRfY29udHJhY3RzLmhlbGxvX3dvcmxkLmNvbnRyYWN0LkhlbGxvV29ybGQuX19wdXlhX2FyYzRfcm91dGVyX18oKSAtPiB1aW50NjQ6Cl9fcHV5YV9hcmM0X3JvdXRlcl9fOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHByb3RvIDAgMQogICAgdHhuIE51bUFwcEFyZ3MKICAgIGJ6IF9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1CiAgICBwdXNoYnl0ZXMgMHgwMmJlY2UxMSAvLyBtZXRob2QgImhlbGxvKHN0cmluZylzdHJpbmciCiAgICB0eG5hIEFwcGxpY2F0aW9uQXJncyAwCiAgICBtYXRjaCBfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyCiAgICBpbnRjXzAgLy8gMAogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19oZWxsb19yb3V0ZUAyOgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjYKICAgIC8vIEBhYmltZXRob2QoKQogICAgdHhuIE9uQ29tcGxldGlvbgogICAgIQogICAgYXNzZXJ0IC8vIE9uQ29tcGxldGlvbiBpcyBub3QgTm9PcAogICAgdHhuIEFwcGxpY2F0aW9uSUQKICAgIGFzc2VydCAvLyBjYW4gb25seSBjYWxsIHdoZW4gbm90IGNyZWF0aW5nCiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgdHhuYSBBcHBsaWNhdGlvbkFyZ3MgMQogICAgZXh0cmFjdCAyIDAKICAgIC8vIHNtYXJ0X2NvbnRyYWN0cy9oZWxsb193b3JsZC9jb250cmFjdC5weTo2CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIGNhbGxzdWIgaGVsbG8KICAgIGR1cAogICAgbGVuCiAgICBpdG9iCiAgICBleHRyYWN0IDYgMgogICAgc3dhcAogICAgY29uY2F0CiAgICBwdXNoYnl0ZXMgMHgxNTFmN2M3NQogICAgc3dhcAogICAgY29uY2F0CiAgICBsb2cKICAgIGludGNfMSAvLyAxCiAgICByZXRzdWIKCl9fcHV5YV9hcmM0X3JvdXRlcl9fX2JhcmVfcm91dGluZ0A1OgogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjUKICAgIC8vIGNsYXNzIEhlbGxvV29ybGQoQVJDNENvbnRyYWN0KToKICAgIHR4biBPbkNvbXBsZXRpb24KICAgIGJueiBfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDkKICAgIHR4biBBcHBsaWNhdGlvbklECiAgICAhCiAgICBhc3NlcnQgLy8gY2FuIG9ubHkgY2FsbCB3aGVuIGNyZWF0aW5nCiAgICBpbnRjXzEgLy8gMQogICAgcmV0c3ViCgpfX3B1eWFfYXJjNF9yb3V0ZXJfX19hZnRlcl9pZl9lbHNlQDk6CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6NQogICAgLy8gY2xhc3MgSGVsbG9Xb3JsZChBUkM0Q29udHJhY3QpOgogICAgaW50Y18wIC8vIDAKICAgIHJldHN1YgoKCi8vIHNtYXJ0X2NvbnRyYWN0cy5oZWxsb193b3JsZC5jb250cmFjdC5IZWxsb1dvcmxkLmhlbGxvKG5hbWU6IGJ5dGVzKSAtPiBieXRlczoKaGVsbG86CiAgICAvLyBzbWFydF9jb250cmFjdHMvaGVsbG9fd29ybGQvY29udHJhY3QucHk6Ni03CiAgICAvLyBAYWJpbWV0aG9kKCkKICAgIC8vIGRlZiBoZWxsbyhzZWxmLCBuYW1lOiBTdHJpbmcpIC0+IFN0cmluZzoKICAgIHByb3RvIDEgMQogICAgLy8gc21hcnRfY29udHJhY3RzL2hlbGxvX3dvcmxkL2NvbnRyYWN0LnB5OjgKICAgIC8vIHJldHVybiAiSGVsbG8sICIgKyBuYW1lCiAgICBwdXNoYnl0ZXMgIkhlbGxvLCAiCiAgICBmcmFtZV9kaWcgLTEKICAgIGNvbmNhdAogICAgcmV0c3ViCg==",
91+
"clear": "I3ByYWdtYSB2ZXJzaW9uIDEwCgpzbWFydF9jb250cmFjdHMuaGVsbG9fd29ybGQuY29udHJhY3QuSGVsbG9Xb3JsZC5jbGVhcl9zdGF0ZV9wcm9ncmFtOgogICAgcHVzaGludCAxIC8vIDEKICAgIHJldHVybgo="
92+
},
93+
"events": [],
94+
"templateVariables": {}
95+
}

0 commit comments

Comments
 (0)