Skip to content

Commit

Permalink
feat: add generate client command (#266)
Browse files Browse the repository at this point in the history
  • Loading branch information
negar-abbasi authored May 25, 2023
1 parent 6e937a8 commit b885fb1
Show file tree
Hide file tree
Showing 17 changed files with 904 additions and 389 deletions.
50 changes: 46 additions & 4 deletions docs/cli/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@
- [explore](#explore)
- [Arguments](#arguments-1)
- [NETWORK](#network)
- [goal](#goal)
- [generate](#generate)
- [client](#client)
- [Options](#options-4)
- [-a, --appspec ](#-a---appspec-)
- [-o, --output ](#-o---output-)
- [--language ](#--language-)
- [goal](#goal)
- [Options](#options-5)
- [--console](#--console)
- [Arguments](#arguments-2)
- [GOAL_ARGS](#goal_args)
- [init](#init)
- [Options](#options-5)
- [Options](#options-6)
- [-n, --name ](#-n---name-)
- [-t, --template ](#-t---template-)
- [--template-url ](#--template-url-)
Expand All @@ -50,11 +56,11 @@
- [console](#console)
- [explore](#explore-1)
- [logs](#logs)
- [Options](#options-6)
- [Options](#options-7)
- [--follow, -f](#--follow--f)
- [--tail ](#--tail-)
- [reset](#reset)
- [Options](#options-7)
- [Options](#options-8)
- [--update, --no-update](#--update---no-update)
- [start](#start)
- [status](#status)
Expand Down Expand Up @@ -237,6 +243,42 @@ algokit explore [OPTIONS] [[localnet|testnet|mainnet]]
### NETWORK
Optional argument

## generate

Generate code for an Algorand project.

```shell
algokit generate [OPTIONS] COMMAND [ARGS]...
```

### client

Create a typed ApplicationClient from an ARC-32 application.json

```shell
algokit generate client [OPTIONS]
```

### Options


### -a, --appspec <app_spec>
Path to an application specification file or a directory to recursively search for application.json


### -o, --output <output>
Path to the output file. The following tokens can be used to substitute into the output path: %name%, %parent_dir%


### --language <language>
Programming language of the generated client code


* **Options**

python | typescript


## goal

Run the Algorand goal CLI against the AlgoKit LocalNet.
Expand Down
889 changes: 504 additions & 385 deletions poetry.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ copier = "^7.1.0"
questionary = "^1.10.0"
pyclip = "^0.7.0"
shellingham = "^1.5.0.post1"
algokit-client-generator = "^0.1.0b6"

[tool.poetry.group.dev.dependencies]
pytest = "^7.2.0"
Expand Down
2 changes: 2 additions & 0 deletions src/algokit/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from algokit.cli.config import config_group
from algokit.cli.doctor import doctor_command
from algokit.cli.explore import explore_command
from algokit.cli.generate import generate_group
from algokit.cli.goal import goal_command
from algokit.cli.init import init_command
from algokit.cli.localnet import localnet_group
Expand Down Expand Up @@ -41,3 +42,4 @@ def algokit(*, skip_version_check: bool) -> None:
algokit.add_command(goal_command)
algokit.add_command(init_command)
algokit.add_command(localnet_group)
algokit.add_command(generate_group)
122 changes: 122 additions & 0 deletions src/algokit/cli/generate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import json
import logging
import pathlib
import platform
import re

import algokit_client_generator
import click

from algokit.core import proc

logger = logging.getLogger(__name__)


def snake_case(s: str) -> str:
s = s.replace("-", " ")
s = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", s)
s = re.sub(r"([a-z\d])([A-Z])", r"\1_\2", s)
return re.sub(r"[-\s]", "_", s).lower()


def format_client_name(output: pathlib.Path, application_file: pathlib.Path) -> pathlib.Path:
client_name = str(output).replace("%parent_dir%", snake_case(application_file.parent.name))

if "%name%" in str(output):
application_json = json.loads(application_file.read_text())
client_name = str(output).replace("%name%", snake_case(application_json["contract"]["name"]))

return pathlib.Path(client_name)


def generate_client_by_language(app_spec: pathlib.Path, output: pathlib.Path, language: str) -> None:
if language.lower() == "python":
logger.info(f"Generating Python client code for application specified in {app_spec} and writing to {output}")
algokit_client_generator.generate_client(app_spec, pathlib.Path(output))

elif language.lower() == "typescript":
is_windows = platform.system() == "Windows"
npx = "npx" if not is_windows else "npx.cmd"
cmd = [
npx,
"--yes",
"@algorandfoundation/[email protected]",
"generate",
"-a",
str(app_spec),
"-o",
str(output),
]
try:
proc.run(
cmd,
bad_return_code_error_message=f"Failed to run {' '.join(cmd)} for {app_spec}.",
)
except OSError as e:
raise click.ClickException("Typescript generator requires Node.js and npx to be installed.") from e
logger.info(
f"Generating TypeScript client code for application specified in {app_spec} and writing to {output}"
)


def generate_recursive_clients(app_spec: pathlib.Path, output: pathlib.Path, language: str) -> None:
if app_spec.is_dir():
for child in app_spec.iterdir():
if child.is_dir():
generate_recursive_clients(app_spec=child, output=output, language=language)
elif child.name.lower() == "application.json":
formatted_output = format_client_name(output=output, application_file=child)
generate_client_by_language(app_spec=child, output=formatted_output, language=language)
else:
formatted_output = format_client_name(output=output, application_file=app_spec)
generate_client_by_language(app_spec=app_spec, output=formatted_output, language=language)


@click.group("generate")
def generate_group() -> None:
"""Generate code for an Algorand project."""


@generate_group.command("client")
@click.option(
"app_spec",
"--appspec",
"-a",
type=click.Path(exists=True, dir_okay=True, resolve_path=True),
default="./application.json",
help="Path to an application specification file or a directory to recursively search for application.json",
)
@click.option(
"output",
"--output",
"-o",
type=click.Path(exists=False, dir_okay=False, resolve_path=True),
default="./client_generated.py",
help="Path to the output file. The following tokens can be used to substitute into the output path:"
" %name%, %parent_dir% ",
)
@click.option(
"--language",
default=None,
type=click.Choice(["python", "typescript"]),
help="Programming language of the generated client code",
)
def generate_client(app_spec: str, output: str, language: str | None) -> None:
"""
Create a typed ApplicationClient from an ARC-32 application.json
"""
output_path = pathlib.Path(output)
app_spec_path = pathlib.Path(app_spec)
if language is None:
extension = output_path.suffix
if extension == ".ts":
language = "typescript"
elif extension == ".py":
language = "python"
else:
raise click.ClickException(
"Could not determine language from file extension, Please use the --language option to specify a "
"target language"
)

generate_recursive_clients(app_spec=app_spec_path, output=output_path, language=language)
75 changes: 75 additions & 0 deletions tests/generate/application.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"
}
}
Loading

1 comment on commit b885fb1

@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__.py220%1–3
src/algokit/cli
   completions.py105298%80, 95
   doctor.py48394%142–144
   generate.py60788%64–69, 117
   goal.py30197%42
   init.py1841492%300, 303–305, 316, 360, 386, 426, 435–437, 440–445, 458
   localnet.py91397%157, 178–179
src/algokit/core
   bootstrap.py1331688%109, 132, 197, 200, 206–220
   conf.py30487%13, 17, 25, 27
   doctor.py65789%67–69, 92–94, 134
   log_handlers.py68790%50–51, 63, 112–116, 125
   proc.py45198%98
   sandbox.py1171587%84–91, 102, 167, 183, 198–200, 216
   version_prompt.py73889%27–28, 40, 59–62, 80, 109
TOTAL12239792% 

Tests Skipped Failures Errors Time
197 0 💤 0 ❌ 0 🔥 24.972s ⏱️

Please sign in to comment.