diff --git a/docs/cli/index.md b/docs/cli/index.md index bcceabdf..2854e8bf 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -124,7 +124,7 @@ - [--interactive, --non-interactive, --ci](#--interactive---non-interactive---ci-1) - [deploy](#deploy) - [Options](#options-21) - - [-C, --command ](#-c---command-) + - [-C, -c, --command ](#-c--c---command-) - [--interactive, --non-interactive, --ci](#--interactive---non-interactive---ci-2) - [-P, --path ](#-p---path-) - [--deployer ](#--deployer-) @@ -132,6 +132,7 @@ - [-p, --project-name ](#-p---project-name--1) - [Arguments](#arguments-8) - [ENVIRONMENT_NAME](#environment_name) + - [EXTRA_ARGS](#extra_args) - [link](#link) - [Options](#options-22) - [-p, --project-name ](#-p---project-name--2) @@ -916,13 +917,13 @@ algokit project bootstrap poetry [OPTIONS] Deploy smart contracts from AlgoKit compliant repository. ```shell -algokit project deploy [OPTIONS] [ENVIRONMENT_NAME] +algokit project deploy [OPTIONS] [ENVIRONMENT_NAME] [EXTRA_ARGS]... ``` ### Options -### -C, --command +### -C, -c, --command Custom deploy command. If not provided, will load the deploy command from .algokit.toml file. @@ -951,6 +952,10 @@ Specify the project directory. If not provided, current working directory will b ### ENVIRONMENT_NAME Optional argument + +### EXTRA_ARGS +Optional argument(s) + ### link Automatically invoke 'algokit generate client' on contract projects available in the workspace. diff --git a/docs/features/project/deploy.md b/docs/features/project/deploy.md index b9eb31d4..859938d6 100644 --- a/docs/features/project/deploy.md +++ b/docs/features/project/deploy.md @@ -7,7 +7,7 @@ Deploy your smart contracts effortlessly to various networks with the algokit pr ## Usage ```sh -$ algokit project deploy [OPTIONS] [ENVIRONMENT_NAME] +$ algokit project deploy [OPTIONS] [ENVIRONMENT_NAME] [EXTRA_ARGS] ``` This command deploys smart contracts from an AlgoKit compliant repository to the specified network. @@ -22,6 +22,7 @@ This command deploys smart contracts from an AlgoKit compliant repository to the - `-p, --project-name`: (Optional) Projects to execute the command on. Defaults to all projects found in the current directory. Option is mutually exclusive with `--command`. - `-h, --help`: Show this message and exit. +- `[EXTRA_ARGS]...`: Additional arguments to pass to the deploy command. For instance, `algokit project deploy -- {custom args}`. This will ensure that the extra arguments are passed to the deploy command specified in the `.algokit.toml` file or directly via `--command` option. ## Environment files @@ -183,6 +184,20 @@ Example: $ algokit project deploy testnet --ci ``` +## Passing Extra Arguments + +You can pass additional arguments to the deploy command. These extra arguments will be appended to the end of the deploy command specified in your `.algokit.toml` file or to the command specified directly via `--command` option. + +To pass extra arguments, use `--` after the AlgoKit command and options to mark the distinction between arguments used by the CLI and arguments to be passed as extras to the deploy command/script. + +Example: + +```sh +$ algokit project deploy testnet -- my_contract_name --some_contract_related_param +``` + +In this example, `my_contract_name` and `--some_contract_related_param` are extra arguments that can be utilized by the custom deploy command invocation, for instance, to filter the deployment to a specific contract or modify deployment behavior. + ## Example of a Full Deployment ```sh diff --git a/docs/features/project/run.md b/docs/features/project/run.md index 3c86e8bd..a6031e28 100644 --- a/docs/features/project/run.md +++ b/docs/features/project/run.md @@ -10,6 +10,20 @@ $ algokit project run [OPTIONS] COMMAND [ARGS] This command executes a custom command defined in the `.algokit.toml` file of the current project or workspace. +### Options + +- `-l, --list`: List all projects associated with the workspace command. (Optional) +- `-p, --project-name`: Execute the command on specified projects. Defaults to all projects in the current directory. (Optional) +- `-t, --type`: Limit execution to specific project types if executing from workspace. (Optional) +- `-s, --sequential`: Execute workspace commands sequentially, for cases where you do not have a preference on the execution order, but want to disable concurrency. (Optional, defaults to concurrent) +- `[ARGS]...`: Additional arguments to pass to the custom command. These will be appended to the end of the command specified in the `.algokit.toml` file. + +To get detailed help on the above options, execute: + +```bash +algokit project run {name_of_your_command} --help +``` + ### Workspace vs Standalone Projects AlgoKit supports two main types of project structures: Workspaces and Standalone Projects. This flexibility caters to the diverse needs of developers, whether managing multiple related projects or focusing on a single application. @@ -113,27 +127,57 @@ Executing `algokit project run hello` from the root of the workspace will concur Executing `algokit project run hello` from the root of `project_(a|b)` will execute `echo hello` in the `project_(a|b)` directory. -### Controlling order of execution +### Controlling Execution Order -To control order of execution, simply define the order for a particular command as follows: +Customize the execution order of commands in workspaces for precise control: -```yaml -# ... other non [project.run] related metadata -[project] -type = 'workspace' -projects_root_path = 'projects' +1. Define order in `.algokit.toml`: -[project.run] -hello = ['project_a', 'project_b'] -# ... other non [project.run] related metadata -``` + ```yaml + [project] + type = 'workspace' + projects_root_path = 'projects' + + [project.run] + hello = ['project_a', 'project_b'] + ``` + +2. Execution behavior: + - Projects are executed in the specified order + - Invalid project names are skipped + - Partial project lists: Specified projects run first, others follow + +> Note: Explicit order always triggers sequential execution. + +### Controlling Concurrency + +You can control whether commands are executed concurrently or sequentially: + +1. Use command-line options: -Now if project_a and project_b are both defined as standalone projects, the order of execution will be respected. Additional behaviour can be described as follows: + ```sh + $ algokit project run hello -s # or --sequential + $ algokit project run hello -c # or --concurrent + ``` -- Providing invalid project names will skip the execution of the command for the invalid project names. -- If only a subset of projects declaring this command are specified, the order of execution will be respected for the subset of projects first before the rest of the projects are executed in non-deterministic order. +2. Behavior: + - Default: Concurrent execution + - Sequential: Use `-s` or `--sequential` flag + - Concurrent: Use `-c` or `--concurrent` flag or omit the flag (defaults to concurrent) + +> Note: When an explicit order is specified in `.algokit.toml`, execution is always sequential regardless of these flags. + +### Passing Extra Arguments + +You can pass additional arguments to the custom command. These extra arguments will be appended to the end of the command specified in your `.algokit.toml` file. + +Example: + +```sh +$ algokit project run hello -- world +``` -> Please note, when enabling explicit order of execution all commands will always run sequentially. +In this example, if the `hello` command in `.algokit.toml` is defined as `echo "Hello"`, the actual command executed will be `echo "Hello" world`. ## Further Reading diff --git a/src/algokit/cli/common/utils.py b/src/algokit/cli/common/utils.py index 55b21220..412c7004 100644 --- a/src/algokit/cli/common/utils.py +++ b/src/algokit/cli/common/utils.py @@ -2,10 +2,12 @@ import typing as t +from pathlib import Path import click from algokit.cli.common.constants import ExplorerEntityType +from algokit.core.utils import is_windows class MutuallyExclusiveOption(click.Option): @@ -101,3 +103,50 @@ def get_explorer_url(identifier: str | int, network: str, entity_type: ExplorerE """ return f"https://explore.algokit.io/{network}/{entity_type.value}/{identifier}" + + +def sanitize_extra_args(extra_args: t.Sequence[str]) -> tuple[str, ...]: + """ + Sanitizes and formats extra arguments for command execution across different OSes. + + Args: + extra_args (t.Sequence[str]): A sequence of extra arguments to sanitize. + + Returns: + tuple[str, ...]: A sanitized list of extra arguments. + + Examples: + >>> sanitize_extra_args(["arg1", "arg with spaces", "--flag=value"]) + ['arg1', 'arg with spaces', '--flag=value'] + >>> sanitize_extra_args(("--extra bla bla bla",)) + ['--extra', 'bla', 'bla', 'bla'] + >>> sanitize_extra_args(["--complex='quoted value'", "--multi word"]) + ["--complex='quoted value'", '--multi', 'word'] + >>> sanitize_extra_args([r"C:\\Program Files\\My App", "%PATH%"]) + ['C:\\Program Files\\My App', '%PATH%'] + """ + + lex = __import__("mslex" if is_windows() else "shlex") + + def sanitize_arg(arg: str) -> str: + # Normalize path separators + arg = str(Path(arg)) + + # Handle environment variables + if arg.startswith("%") and arg.endswith("%"): + return arg # Keep Windows-style env vars as-is + elif arg.startswith("$"): + return arg # Keep Unix-style env vars as-is + + # Escape special characters and handle Unicode + return lex.quote(arg) # type: ignore[no-any-return] + + sanitized_args: tuple[str, ...] = () + for arg in extra_args: + # Split the argument if it contains multiple space-separated values + split_args = lex.split(arg) + for split_arg in split_args: + sanitized_arg = sanitize_arg(split_arg) + sanitized_args += (sanitized_arg,) + + return sanitized_args diff --git a/src/algokit/cli/project/deploy.py b/src/algokit/cli/project/deploy.py index 1c3d1692..9d717063 100644 --- a/src/algokit/cli/project/deploy.py +++ b/src/algokit/cli/project/deploy.py @@ -6,7 +6,7 @@ import click from algosdk.mnemonic import from_private_key -from algokit.cli.common.utils import MutuallyExclusiveOption +from algokit.cli.common.utils import MutuallyExclusiveOption, sanitize_extra_args from algokit.core import proc from algokit.core.conf import ALGOKIT_CONFIG, get_algokit_config from algokit.core.project import ProjectType, get_project_configs @@ -88,6 +88,7 @@ def _execute_deploy_command( # noqa: PLR0913 interactive: bool, deployer_alias: str | None, dispenser_alias: str | None, + extra_args: tuple[str, ...], ) -> None: logger.debug(f"Deploying from project directory: {path}") logger.debug("Loading deploy command from project config") @@ -103,7 +104,7 @@ def _execute_deploy_command( # noqa: PLR0913 "and no generic command available." ) raise click.ClickException(msg) - resolved_command = resolve_command_path(config.command) + resolved_command = resolve_command_path(config.command + list(extra_args)) logger.info(f"Using deploy command: {' '.join(resolved_command)}") logger.info("Loading deployment environment variables...") config_dotenv = load_deploy_env_files(environment_name, path) @@ -131,7 +132,7 @@ def _execute_deploy_command( # noqa: PLR0913 raise click.ClickException(f"Deployment command exited with error code = {result.exit_code}") -class CommandParamType(click.types.StringParamType): +class _CommandParamType(click.types.StringParamType): name = "command" def convert( @@ -148,12 +149,47 @@ def convert( raise click.BadParameter(str(ex), param=param, ctx=ctx) from ex -@click.command("deploy") -@click.argument("environment_name", default=None, required=False) +class _DeployCommand(click.Command): + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + # Join all args into a single string + full_command = " ".join(args) + + try: + separator_index = full_command.find("-- ") + if separator_index == -1: + raise ValueError("No separator found") + main_args = args[:separator_index] + extra_args = args[separator_index + 1 :] + except Exception: + main_args = args + extra_args = [] + + # Ensure we have at least one argument for environment_name if extra_args exist + if extra_args and len(main_args) == 0: + main_args.insert(0, "") + + # Reconstruct args list + args = main_args + (["--"] if extra_args else []) + extra_args + + return super().parse_args(ctx, args) + + +@click.command( + "deploy", + context_settings={"ignore_unknown_options": True}, + cls=_DeployCommand, +) +@click.argument( + "environment_name", + default=None, + required=False, + callback=lambda _, __, value: None if value == "" else value, +) @click.option( "--command", "-C", - type=CommandParamType(), + "-c", + type=_CommandParamType(), default=None, help=("Custom deploy command. If not provided, will load the deploy command " "from .algokit.toml file."), required=False, @@ -209,6 +245,11 @@ def convert( "command", ], ) +@click.argument( + "extra_args", + nargs=-1, + required=False, +) @click.pass_context def deploy_command( # noqa: PLR0913 ctx: click.Context, @@ -219,9 +260,11 @@ def deploy_command( # noqa: PLR0913 path: Path, deployer_alias: str | None, dispenser_alias: str | None, - project_names: tuple[str], + project_names: tuple[str, ...], + extra_args: tuple[str, ...], ) -> None: """Deploy smart contracts from AlgoKit compliant repository.""" + extra_args = sanitize_extra_args(extra_args) if ctx.parent and ctx.parent.command.name == "algokit": click.secho( @@ -272,6 +315,7 @@ def deploy_command( # noqa: PLR0913 interactive=interactive, deployer_alias=deployer_alias, dispenser_alias=dispenser_alias, + extra_args=extra_args, ) else: _execute_deploy_command( @@ -281,4 +325,5 @@ def deploy_command( # noqa: PLR0913 interactive=interactive, deployer_alias=deployer_alias, dispenser_alias=dispenser_alias, + extra_args=extra_args, ) diff --git a/src/algokit/cli/project/run.py b/src/algokit/cli/project/run.py index 2d2cc966..1ef8bf45 100644 --- a/src/algokit/cli/project/run.py +++ b/src/algokit/cli/project/run.py @@ -4,7 +4,7 @@ import click -from algokit.cli.common.utils import MutuallyExclusiveOption +from algokit.cli.common.utils import MutuallyExclusiveOption, sanitize_extra_args from algokit.core.project import ProjectType from algokit.core.project.run import ( ProjectCommand, @@ -41,13 +41,14 @@ def _load_project_commands(project_dir: Path) -> dict[str, click.Command]: for custom_command in custom_commands: # Define the base command function - def base_command( + def base_command( # noqa: PLR0913 *, - args: list[str], custom_command: ProjectCommand | WorkspaceProjectCommand = custom_command, project_names: tuple[str] | None = None, list_projects: bool = False, project_type: str | None = None, + sequential: bool = False, + extra_args: tuple[str, ...] | None = None, ) -> None: """ Executes a base command function with optional parameters for listing projects or specifying project names. @@ -57,32 +58,36 @@ def base_command( within a workspace. Args: - args (list[str]): The command arguments to be passed to the custom command. + extra_args (tuple[str, ...]): The command arguments to be passed to the custom command. custom_command (ProjectCommand | WorkspaceProjectCommand): The custom command to be executed. project_names (list[str] | None): Optional. A list of project names to execute the command on. list_projects (bool): Optional. A flag indicating whether to list projects associated with a workspace command. project_type (str | None): Optional. Only execute commands in projects of specified type. - + sequential (bool): Whether to execute wokspace commands sequentially. Defaults to False. Returns: None """ - if args: - logger.warning("Ignoring unrecognized arguments: %s.", " ".join(args)) - + extra_args = sanitize_extra_args(extra_args or ()) if list_projects and isinstance(custom_command, WorkspaceProjectCommand): for command in custom_command.commands: cmds = " && ".join(" ".join(cmd) for cmd in command.commands) logger.info(f"ℹ️ Project: {command.project_name}, Command name: {command.name}, Command(s): {cmds}") # noqa: RUF001 return - run_command(command=custom_command) if isinstance( + run_command(command=custom_command, extra_args=extra_args) if isinstance( custom_command, ProjectCommand - ) else run_workspace_command(custom_command, list(project_names or []), project_type) + ) else run_workspace_command( + workspace_command=custom_command, + project_names=list(project_names or []), + project_type=project_type, + sequential=sequential, + extra_args=extra_args, + ) # Check if the command is a WorkspaceProjectCommand and conditionally decorate is_workspace_command = isinstance(custom_command, WorkspaceProjectCommand) - command = click.argument("args", nargs=-1, type=click.UNPROCESSED, required=False)(base_command) + command = click.argument("extra_args", nargs=-1, type=click.UNPROCESSED, required=False)(base_command) if is_workspace_command: command = click.option( "project_names", @@ -118,6 +123,15 @@ def base_command( default=None, help="Limit execution to specific project types if executing from workspace. (Optional)", )(command) + command = click.option( + "sequential", + "--sequential/--concurrent", + "-s/-c", + help="Execute workspace commands sequentially. Defaults to concurrent.", + default=False, + is_flag=True, + required=False, + )(command) # Apply the click.command decorator with common options command = click.command( diff --git a/src/algokit/core/project/__init__.py b/src/algokit/core/project/__init__.py index 08de5a2a..cd3ee284 100644 --- a/src/algokit/core/project/__init__.py +++ b/src/algokit/core/project/__init__.py @@ -75,7 +75,7 @@ def get_project_configs( working directory is used. lookup_level (int): The number of levels to go up the directory to search for workspace projects project_type (str | None): The type of project to filter by. If None, all project types are returned. - project_names (list[str] | None): The names of the projects to filter by. If None, all projects are returned. + project_names (tuple[str, ...] | None): The names of the projects to filter by. If None, gets all projects. Returns: list[dict[str, Any] | None]: A list of dictionaries, each containing the configuration of an algokit project. diff --git a/src/algokit/core/project/run.py b/src/algokit/core/project/run.py index 60afb0bf..dfdd7bb9 100644 --- a/src/algokit/core/project/run.py +++ b/src/algokit/core/project/run.py @@ -1,7 +1,7 @@ import dataclasses import logging import os -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor from pathlib import Path from typing import Any @@ -162,12 +162,15 @@ def _load_commands_from_workspace( return list(workspace_commands.values()) -def run_command(*, command: ProjectCommand, from_workspace: bool = False) -> None: +def run_command( + *, command: ProjectCommand, from_workspace: bool = False, extra_args: tuple[str, ...] | None = None +) -> None: """Executes a specified project command. Args: command (ProjectCommand): The project command to be executed. from_workspace (bool): Indicates whether the command is being executed from a workspace context. + extra_args (tuple[str, ...] | None): Optional; additional arguments to pass to the command. Raises: click.ClickException: If the command execution fails. @@ -186,6 +189,8 @@ def run_command(*, command: ProjectCommand, from_workspace: bool = False) -> Non for index, cmd in enumerate(command.commands): try: resolved_command = resolve_command_path(cmd) + if index == len(command.commands) - 1 and extra_args: + resolved_command.extend(extra_args) except click.ClickException as e: logger.error(f"'{command.name}' failed executing: '{' '.join(cmd)}'") raise e @@ -208,14 +213,19 @@ def run_command(*, command: ProjectCommand, from_workspace: bool = False) -> Non if is_verbose: log_msg = f"Command Executed: '{' '.join(cmd)}'\nOutput: {result.output}\n" if index == len(command.commands) - 1: + if extra_args: + log_msg += f"Extra Args: '{' '.join(extra_args)}'\n" log_msg += f"✅ {command.project_name}: '{' '.join(cmd)}' executed successfully." logger.info(log_msg) def run_workspace_command( + *, workspace_command: WorkspaceProjectCommand, project_names: list[str] | None = None, project_type: str | None = None, + sequential: bool = False, + extra_args: tuple[str, ...] | None = None, ) -> None: """Executes a workspace command, potentially limited to specified projects. @@ -223,50 +233,49 @@ def run_workspace_command( workspace_command (WorkspaceProjectCommand): The workspace command to be executed. project_names (list[str] | None): Optional; specifies a subset of projects to execute the command for. project_type (str | None): Optional; specifies a subset of project types to execute the command for. + sequential (bool): Whether to execute commands sequentially. Defaults to False. + extra_args (tuple[str, ...] | None): Optional; additional arguments to pass to the command. """ def _execute_command(cmd: ProjectCommand) -> None: """Helper function to execute a single project command within the workspace context.""" logger.info(f"⏳ {cmd.project_name}: '{cmd.name}' command in progress...") try: - run_command(command=cmd, from_workspace=True) + run_command(command=cmd, from_workspace=True, extra_args=extra_args or ()) executed_commands = " && ".join(" ".join(command) for command in cmd.commands) + if extra_args: + executed_commands += f" {' '.join(extra_args)}" logger.info(f"✅ {cmd.project_name}: '{executed_commands}' executed successfully.") except Exception as e: logger.error(f"❌ {cmd.project_name}: {e}") raise click.ClickException(f"failed to execute '{cmd.name}' command in '{cmd.project_name}'") from e - if workspace_command.execution_order: - logger.info("Detected execution order, running commands sequentially") - order_map = {name: i for i, name in enumerate(workspace_command.execution_order)} - sorted_commands = sorted( - workspace_command.commands, key=lambda c: order_map.get(c.project_name, len(order_map)) + def _filter_command(cmd: ProjectCommand) -> bool: + return (not project_names or cmd.project_name in project_names) and ( + not project_type or project_type == cmd.project_type ) - if project_names: - existing_projects = {cmd.project_name for cmd in workspace_command.commands} - missing_projects = set(project_names) - existing_projects - if missing_projects: - logger.warning(f"Missing projects: {', '.join(missing_projects)}. Proceeding with available ones.") - - for cmd in sorted_commands: - if ( - project_names - and cmd.project_name not in project_names - or (project_type and project_type != cmd.project_type) - ): - continue + is_sequential = workspace_command.execution_order or sequential + logger.info(f"Running commands {'sequentially' if is_sequential else 'concurrently'}.") + + filtered_commands = list(filter(_filter_command, workspace_command.commands)) + + if project_names: + existing_projects = {cmd.project_name for cmd in filtered_commands} + missing_projects = set(project_names) - existing_projects + if missing_projects: + logger.warning(f"Missing projects: {', '.join(missing_projects)}. Proceeding with available ones.") + + if is_sequential: + if workspace_command.execution_order: + order_map = {name: i for i, name in enumerate(workspace_command.execution_order)} + filtered_commands.sort(key=lambda c: order_map.get(c.project_name, len(order_map))) + + for cmd in filtered_commands: _execute_command(cmd) else: with ThreadPoolExecutor() as executor: - futures = { - executor.submit(_execute_command, cmd): cmd - for cmd in workspace_command.commands - if (not project_names or cmd.project_name in project_names) - and (not project_type or project_type == cmd.project_type) - } - for future in as_completed(futures): - future.result() + list(executor.map(_execute_command, filtered_commands)) def load_commands(project_dir: Path) -> list[ProjectCommand] | list[WorkspaceProjectCommand] | None: diff --git a/tests/portability/test_pyinstaller_binary.py b/tests/portability/test_pyinstaller_binary.py index b126d608..7566a20e 100644 --- a/tests/portability/test_pyinstaller_binary.py +++ b/tests/portability/test_pyinstaller_binary.py @@ -19,16 +19,22 @@ def command_str_to_list(command: str) -> list[str]: @pytest.mark.parametrize( ("command", "exit_codes"), [ - (command_str_to_list("--help"), [0]), - (command_str_to_list("doctor"), [0]), - (command_str_to_list("task vanity-address PY"), [0]), + (command_str_to_list("algokit --help"), [0]), + (command_str_to_list("algokit doctor"), [0]), + (command_str_to_list("algokit task vanity-address PY"), [0]), ], ) def test_non_interactive_algokit_commands( command: list[str], exit_codes: list[int], tmp_path_factory: pytest.TempPathFactory ) -> None: cwd = tmp_path_factory.mktemp("cwd") - execution_result = subprocess.run([algokit, *command], capture_output=True, text=True, check=False, cwd=cwd) + + # Create a 'playground' directory + if "build" in command: + cwd = cwd / "playground" + cwd.mkdir(exist_ok=True) + + execution_result = subprocess.run(command, capture_output=True, text=True, check=False, cwd=cwd) logger.info(f"Command {command} returned {execution_result.stdout}") # Parts of doctor will fail in CI on macOS and windows on github actions since docker isn't available by default @@ -38,7 +44,25 @@ def test_non_interactive_algokit_commands( assert execution_result.returncode in exit_codes, f"Command {command} failed with {execution_result.stderr}" -def test_algokit_init( +def test_algokit_init_and_project_run(tmp_path_factory: pytest.TempPathFactory) -> None: + cwd = tmp_path_factory.mktemp("cwd") + + # Run algokit init + init_command = command_str_to_list("algokit init --name playground -t python --no-git --no-ide --defaults") + init_result = subprocess.run(init_command, capture_output=True, text=True, check=False, cwd=cwd) + logger.info(f"Command {init_command} returned {init_result.stdout}") + assert init_result.returncode == 0, f"Init command failed with {init_result.stderr}" + + # Run algokit project run build + build_cwd = cwd / "playground" + build_cwd.mkdir(exist_ok=True) + build_command = command_str_to_list("algokit -v project run build -- hello_world") + build_result = subprocess.run(build_command, capture_output=True, text=True, check=False, cwd=build_cwd) + logger.info(f"Command {build_command} returned {build_result.stdout}") + assert build_result.returncode == 0, f"Build command failed with {build_result.stderr}" + + +def test_algokit_init_with_template_url( dummy_algokit_template_with_python_task: dict[str, Path], ) -> None: # TODO: revisit to improve diff --git a/tests/project/deploy/test_deploy.py b/tests/project/deploy/test_deploy.py index bc797d4f..795ff784 100644 --- a/tests/project/deploy/test_deploy.py +++ b/tests/project/deploy/test_deploy.py @@ -4,6 +4,7 @@ import pytest from _pytest.tmpdir import TempPathFactory +from algokit.cli.common.utils import sanitize_extra_args from algokit.core.conf import ALGOKIT_CONFIG from algokit.core.tasks.wallet import WALLET_ALIASES_KEYRING_USERNAME from algosdk.account import generate_account @@ -468,3 +469,42 @@ def test_deploy_dispenser_alias( assert passed_env_vars[env_var_name] == from_private_key(dummy_account_pk) # type: ignore[no-untyped-call] verify(result.output, options=NamerFactory.with_parameters(alias)) + + +def test_deploy_with_extra_args(tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock) -> None: + config_with_deploy = """ +[project.deploy] +command = "command_a" + """.strip() + + cwd = tmp_path_factory.mktemp("cwd") + (cwd / ALGOKIT_CONFIG).write_text(config_with_deploy, encoding="utf-8") + (cwd / ".env").touch() + + cmd_resolved = which_mock.add("command_a") + proc_mock.set_output([cmd_resolved], ["command executed"]) + + extra_args = ["--arg1 value1 --arg2 value2"] + result = invoke(["project", "deploy", "--", *extra_args], cwd=cwd) + + assert result.exit_code == 0 + assert proc_mock.called[0].command == [cmd_resolved, *sanitize_extra_args(extra_args)] + verify(result.output) + + +def test_deploy_with_extra_args_and_custom_command( + tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock +) -> None: + cwd = tmp_path_factory.mktemp("cwd") + (cwd / ".env").touch() + + custom_command = "custom_command" + cmd_resolved = which_mock.add(custom_command) + proc_mock.set_output([cmd_resolved], ["custom command executed"]) + + extra_args = ["--custom-arg1 custom-value1 --custom-arg2 custom-value2"] + result = invoke(["project", "deploy", "localnet", "--command", custom_command, "--", *extra_args], cwd=cwd) + + assert result.exit_code == 0 + assert proc_mock.called[0].command == [cmd_resolved, *sanitize_extra_args(extra_args)] + verify(result.output) diff --git a/tests/project/deploy/test_deploy.test_deploy_with_extra_args.approved.txt b/tests/project/deploy/test_deploy.test_deploy_with_extra_args.approved.txt new file mode 100644 index 00000000..b06334c1 --- /dev/null +++ b/tests/project/deploy/test_deploy.test_deploy_with_extra_args.approved.txt @@ -0,0 +1,9 @@ +DEBUG: Attempting to load project config from {current_working_directory}/.algokit.toml +DEBUG: Deploying from project directory: {current_working_directory} +DEBUG: Loading deploy command from project config +DEBUG: Attempting to load project config from {current_working_directory}/.algokit.toml +Using deploy command: /bin/command_a --arg1 value1 --arg2 value2 +Loading deployment environment variables... +Deploying smart contracts from AlgoKit compliant repository 🚀 +DEBUG: Running '/bin/command_a --arg1 value1 --arg2 value2' in '{current_working_directory}' +/bin/command_a: command executed diff --git a/tests/project/deploy/test_deploy.test_deploy_with_extra_args_and_custom_command.approved.txt b/tests/project/deploy/test_deploy.test_deploy_with_extra_args_and_custom_command.approved.txt new file mode 100644 index 00000000..26e687c1 --- /dev/null +++ b/tests/project/deploy/test_deploy.test_deploy_with_extra_args_and_custom_command.approved.txt @@ -0,0 +1,12 @@ +DEBUG: Attempting to load project config from {current_working_directory}/.algokit.toml +DEBUG: No .algokit.toml file found in the project directory. +DEBUG: Deploying from project directory: {current_working_directory} +DEBUG: Loading deploy command from project config +DEBUG: Attempting to load project config from {current_working_directory}/.algokit.toml +DEBUG: No .algokit.toml file found in the project directory. +Using deploy command: /bin/custom_command --custom-arg1 custom-value1 --custom-arg2 custom-value2 +Loading deployment environment variables... +DEBUG: Using default environment config for algod and indexer for network localnet +Deploying smart contracts from AlgoKit compliant repository 🚀 +DEBUG: Running '/bin/custom_command --custom-arg1 custom-value1 --custom-arg2 custom-value2' in '{current_working_directory}' +/bin/custom_command: custom command executed diff --git a/tests/project/run/test_run.py b/tests/project/run/test_run.py index 5c2d4640..00342fd9 100644 --- a/tests/project/run/test_run.py +++ b/tests/project/run/test_run.py @@ -477,3 +477,126 @@ def test_run_command_help_works_without_path_resolution( verify(_format_output(result.output)) assert invoke("project run hello", cwd=cwd).exit_code == 1 + + +def test_run_command_from_workspace_with_sequential_flag( + tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock +) -> None: + cwd = tmp_path_factory.mktemp("cwd") / "algokit_project" + projects = [] + for i in range(1, 6): + projects.append( + { + "dir": f"project{i}", + "type": "contract", + "name": f"contract_project_{i}", + "command": f"hello{i}", + "description": "Prints hello", + } + ) + _create_workspace_project( + workspace_dir=cwd, + projects=projects, + mock_command=True, + which_mock=which_mock, + proc_mock=proc_mock, + ) + + result = invoke("project run hello --sequential", cwd=cwd) + assert result.exit_code == 0 + order_of_execution = [line for line in result.output.split("\n") if line.startswith("✅")] + for i in range(5): + assert f"contract_project_{i + 1}" in order_of_execution[i] + + +def test_run_command_from_workspace_with_order_and_sequential_flag( + tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock +) -> None: + cwd = tmp_path_factory.mktemp("cwd") / "algokit_project" + projects = [] + for i in range(1, 6): + projects.append( + { + "dir": f"project{i}", + "type": "contract", + "name": f"contract_project_{i}", + "command": f"hello{i}", + "description": "Prints hello", + } + ) + _create_workspace_project( + workspace_dir=cwd, + projects=projects, + mock_command=True, + which_mock=which_mock, + proc_mock=proc_mock, + custom_project_order=["contract_project_4"], + ) + + result = invoke("project run hello --sequential", cwd=cwd) + assert result.exit_code == 0 + order_of_execution = [line for line in result.output.split("\n") if line.startswith("✅")] + assert "contract_project_4" in order_of_execution[0] + + +def test_run_command_from_standalone_with_extra_args( + tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock +) -> None: + """ + Verifies successful command execution within a standalone project with extra arguments. + """ + cwd = tmp_path_factory.mktemp("cwd") / "algokit_project" + cwd.mkdir() + + which_mock.add("echo") + proc_mock.set_output(["echo", "Hello", "extra", "args"], ["Hello extra args"]) + _create_project_config(cwd, "contract", "contract_project", "echo Hello", "Prints hello with extra args") + + result = invoke("project run hello -- extra args", cwd=cwd) + + assert result.exit_code == 0 + verify(_format_output(result.output)) + assert "Hello extra args" in result.output + + +def test_run_command_from_workspace_with_extra_args( + tmp_path_factory: TempPathFactory, which_mock: WhichMock, proc_mock: ProcMock +) -> None: + """ + Verifies successful command execution within a workspace project with extra arguments. + """ + cwd = tmp_path_factory.mktemp("cwd") / "algokit_project" + projects = [ + { + "dir": "project1", + "type": "contract", + "name": "contract_project", + "command": "echo Hello", + "description": "Prints hello with extra args", + }, + ] + _create_workspace_project( + workspace_dir=cwd, projects=projects, mock_command=True, which_mock=which_mock, proc_mock=proc_mock + ) + + which_mock.add("echo") + proc_mock.set_output(["echo", "Hello", "extra", "args"], ["Hello extra args"]) + + result = invoke("project run hello -- extra args", cwd=cwd) + + assert result.exit_code == 0 + verify(_format_output(result.output)) + assert "Hello extra args" in result.output + + +def test_run_command_from_workspace_with_extra_args_and_project_filter(cwd_with_workspace_sequential: Path) -> None: + """ + Verifies successful command execution within a workspace project with extra arguments and project filtering. + """ + result = invoke( + "project run hello --project-name 'contract_project' -- extra args", cwd=cwd_with_workspace_sequential + ) + + assert result.exit_code == 0 + verify(_format_output(result.output)) + assert "frontend_project" not in result.output diff --git a/tests/project/run/test_run.test_run_command_from_standalone_with_extra_args.approved.txt b/tests/project/run/test_run.test_run_command_from_standalone_with_extra_args.approved.txt new file mode 100644 index 00000000..9c491369 --- /dev/null +++ b/tests/project/run/test_run.test_run_command_from_standalone_with_extra_args.approved.txt @@ -0,0 +1,6 @@ +Running `hello` command in {current_working_directory}... +Command Executed: 'echo Hello' +Output: STDOUT +STDERR +Extra Args: 'extra args' +✅ contract_project: 'echo Hello' executed successfully. diff --git a/tests/project/run/test_run.test_run_command_from_workspace_execution_error.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_execution_error.approved.txt index 80f7b144..4818b954 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_execution_error.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_execution_error.approved.txt @@ -1,4 +1,4 @@ -Detected execution order, running commands sequentially +Running commands sequentially. ⏳ frontend_project: 'hello' command in progress... ERROR: ····················· project run 'hello' command output: ······················ diff --git a/tests/project/run/test_run.test_run_command_from_workspace_filtered.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_filtered.approved.txt index 1ab29407..952945b8 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_filtered.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_filtered.approved.txt @@ -1,3 +1,3 @@ -Detected execution order, running commands sequentially +Running commands sequentially. ⏳ contract_project: 'hello' command in progress... ✅ contract_project: 'command_a' executed successfully. diff --git a/tests/project/run/test_run.test_run_command_from_workspace_filtered_no_project.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_filtered_no_project.approved.txt index 43b76eb3..6d4ea120 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_filtered_no_project.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_filtered_no_project.approved.txt @@ -1,2 +1,2 @@ -Detected execution order, running commands sequentially +Running commands sequentially. WARNING: Missing projects: contract_project2. Proceeding with available ones. diff --git a/tests/project/run/test_run.test_run_command_from_workspace_resolution_error.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_resolution_error.approved.txt index 58e0dd5a..9de847c7 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_resolution_error.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_resolution_error.approved.txt @@ -1,4 +1,4 @@ -Detected execution order, running commands sequentially +Running commands sequentially. ⏳ frontend_project: 'hello' command in progress... ERROR: 'hello' failed executing: 'failthiscommand' ERROR: ❌ frontend_project: Failed to resolve command path, 'failthiscommand' wasn't found diff --git a/tests/project/run/test_run.test_run_command_from_workspace_sequential_success.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_sequential_success.approved.txt index af8edab5..0d02bf12 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_sequential_success.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_sequential_success.approved.txt @@ -1,4 +1,4 @@ -Detected execution order, running commands sequentially +Running commands sequentially. ⏳ contract_project: 'hello' command in progress... ✅ contract_project: 'command_a' executed successfully. ⏳ frontend_project: 'hello' command in progress... diff --git a/tests/project/run/test_run.test_run_command_from_workspace_success.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_success.approved.txt index 1ab29407..952945b8 100644 --- a/tests/project/run/test_run.test_run_command_from_workspace_success.approved.txt +++ b/tests/project/run/test_run.test_run_command_from_workspace_success.approved.txt @@ -1,3 +1,3 @@ -Detected execution order, running commands sequentially +Running commands sequentially. ⏳ contract_project: 'hello' command in progress... ✅ contract_project: 'command_a' executed successfully. diff --git a/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args.approved.txt new file mode 100644 index 00000000..6776612b --- /dev/null +++ b/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args.approved.txt @@ -0,0 +1,3 @@ +Running commands sequentially. +⏳ contract_project: 'hello' command in progress... +✅ contract_project: 'echo Hello extra args' executed successfully. diff --git a/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args_and_project_filter.approved.txt b/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args_and_project_filter.approved.txt new file mode 100644 index 00000000..fb373b90 --- /dev/null +++ b/tests/project/run/test_run.test_run_command_from_workspace_with_extra_args_and_project_filter.approved.txt @@ -0,0 +1,3 @@ +Running commands sequentially. +⏳ contract_project: 'hello' command in progress... +✅ contract_project: 'command_a extra args' executed successfully.