Skip to content

Commit

Permalink
fix: handle deploy commands on windows that are actually .cmd files…
Browse files Browse the repository at this point in the history
… or similar (#303)

fix: handle deploy commands on windows that are actually `.cmd` files or similar

---------

Co-authored-by: Adam Chidlow <[email protected]>
  • Loading branch information
aorumbayev and achidlow authored Jul 18, 2023
1 parent 1fe759e commit 17791c7
Show file tree
Hide file tree
Showing 18 changed files with 172 additions and 252 deletions.
208 changes: 33 additions & 175 deletions poetry.lock

Large diffs are not rendered by default.

15 changes: 9 additions & 6 deletions src/algokit/cli/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from algokit.core import proc
from algokit.core.conf import ALGOKIT_CONFIG
from algokit.core.deploy import load_deploy_config, load_env_files, parse_command
from algokit.core.deploy import load_deploy_config, load_env_files, parse_command, resolve_command

logger = logging.getLogger(__name__)

Expand All @@ -31,7 +31,7 @@ def convert(
str_value = super().convert(value=value, param=param, ctx=ctx)
try:
return parse_command(str_value)
except Exception as ex:
except ValueError as ex:
logger.debug(f"Failed to parse command string: {str_value}", exc_info=True)
raise click.BadParameter(str(ex), param=param, ctx=ctx) from ex

Expand Down Expand Up @@ -80,7 +80,8 @@ def deploy_command(
"and no generic command."
)
raise click.ClickException(msg)
logger.info(f"Using deploy command: {' '.join(config.command)}")
resolved_command = resolve_command(config.command)
logger.info(f"Using deploy command: {' '.join(resolved_command)}")
# TODO: [future-note] do we want to walk up for env/config?
logger.info("Loading deployment environment variables...")
config_dotenv = load_env_files(environment_name, path)
Expand All @@ -90,11 +91,13 @@ def deploy_command(
_ensure_environment_secrets(config_env, config.environment_secrets, skip_mnemonics_prompts=not interactive)
logger.info("Deploying smart contracts from AlgoKit compliant repository 🚀")
try:
result = proc.run(config.command, cwd=path, env=config_env, stdout_log_level=logging.INFO)
result = proc.run(resolved_command, cwd=path, env=config_env, stdout_log_level=logging.INFO)
except FileNotFoundError as ex:
raise click.ClickException(f"Failed to execute deploy command, '{config.command[0]}' wasn't found") from ex
raise click.ClickException(f"Failed to execute deploy command, '{resolved_command[0]}' wasn't found") from ex
except PermissionError as ex:
raise click.ClickException(f"Failed to execute deploy command '{config.command[0]}', permission denied") from ex
raise click.ClickException(
f"Failed to execute deploy command '{resolved_command[0]}', permission denied"
) from ex
else:
if result.exit_code != 0:
raise click.ClickException(f"Deployment command exited with error code = {result.exit_code}")
15 changes: 14 additions & 1 deletion src/algokit/core/deploy.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import dataclasses
import logging
import platform
import shutil
from pathlib import Path

import click
Expand Down Expand Up @@ -69,7 +70,8 @@ def load_deploy_config(name: str | None, project_dir: Path) -> DeployConfig:
case {"command": str(command)}:
try:
deploy_config.command = parse_command(command)
except Exception as ex:
except ValueError as ex:
logger.debug(f"Failed to parse command string: {command}", exc_info=True)
raise click.ClickException(f"Failed to parse command '{command}': {ex}") from ex
case {"command": list(command_parts)}:
deploy_config.command = [str(x) for x in command_parts]
Expand All @@ -93,3 +95,14 @@ def parse_command(command: str) -> list[str]:
import shlex

return shlex.split(command)


def resolve_command(command: list[str]) -> list[str]:
cmd, *args = command
# if the command has any path separators or such, don't try and resolve
if Path(cmd).name != cmd:
return command
resolved_cmd = shutil.which(cmd)
if not resolved_cmd:
raise click.ClickException(f"Failed to resolve deploy command, '{cmd}' wasn't found")
return [resolved_cmd, *args]
118 changes: 80 additions & 38 deletions tests/deploy/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tests.utils.approvals import verify
from tests.utils.click_invoker import invoke
from tests.utils.proc_mock import ProcMock
from tests.utils.which_mock import WhichMock

PYTHON_EXECUTABLE = sys.executable
# need to use an escaped python executable path in config files for windows
Expand All @@ -19,6 +20,13 @@
TEST_PYTHON_COMMAND = "print(' test_command_invocation ')"


@pytest.fixture(autouse=True)
def which_mock(mocker: MockerFixture) -> WhichMock:
which_mock = WhichMock()
mocker.patch("algokit.core.deploy.shutil.which").side_effect = which_mock.which
return which_mock


def test_algokit_config_empty_array(tmp_path_factory: TempPathFactory) -> None:
empty_array_config = """
[deploy]
Expand Down Expand Up @@ -48,7 +56,9 @@ def test_algokit_config_invalid_syntax(tmp_path_factory: TempPathFactory) -> Non
verify(result.output)


def test_algokit_config_name_overrides(tmp_path_factory: TempPathFactory, proc_mock: ProcMock) -> None:
def test_algokit_config_name_overrides(
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock
) -> None:
config_with_override = """
[deploy]
command = "command_a"
Expand All @@ -65,15 +75,18 @@ def test_algokit_config_name_overrides(tmp_path_factory: TempPathFactory, proc_m
(cwd / ".env.localnet").touch()
(cwd / ".env.testnet").touch()

proc_mock.set_output(["command_c"], ["picked testnet"])
resolved_cmd = which_mock.add("command_c")
proc_mock.set_output([resolved_cmd], ["picked testnet"])

result = invoke(["deploy", "testnet"], cwd=cwd)

assert result.exit_code == 0
verify(result.output)


def test_algokit_config_name_no_base(tmp_path_factory: TempPathFactory, proc_mock: ProcMock) -> None:
def test_algokit_config_name_no_base(
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock
) -> None:
config_with_override = """
[deploy.localnet]
command = "command_a"
Expand All @@ -86,7 +99,8 @@ def test_algokit_config_name_no_base(tmp_path_factory: TempPathFactory, proc_moc
(cwd / ".env.localnet").touch()
(cwd / ".env.testnet").touch()

proc_mock.set_output(["command_a"], ["picked localnet"])
cmd = which_mock.add("command_a")
proc_mock.set_output([cmd], ["picked localnet"])

result = invoke(["deploy", "localnet"], cwd=cwd)

Expand All @@ -113,9 +127,6 @@ def test_command_invocation_and_command_splitting(tmp_path: Path) -> None:


def test_command_splitting_from_config(tmp_path: Path) -> None:
# note: spaces around the string inside print are important,
# we need to test the usage of shlex.split vs str.split, to handle
# splitting inside quotes properly
config_data = rf"""
[deploy]
command = "{PYTHON_EXECUTABLE_ESCAPED} -c \"{TEST_PYTHON_COMMAND}\""
Expand All @@ -127,9 +138,6 @@ def test_command_splitting_from_config(tmp_path: Path) -> None:


def test_command_without_splitting_from_config(tmp_path: Path) -> None:
# note: spaces around the string inside print are important,
# we need to test the usage of shlex.split vs str.split, to handle
# splitting inside quotes properly
config_data = rf"""
[deploy]
command = ["{PYTHON_EXECUTABLE_ESCAPED}", "-c", "{TEST_PYTHON_COMMAND}"]
Expand All @@ -140,31 +148,33 @@ def test_command_without_splitting_from_config(tmp_path: Path) -> None:
verify(result.output.replace(PYTHON_EXECUTABLE, "<sys.executable>"))


def test_command_not_found_and_no_config(proc_mock: ProcMock) -> None:
@pytest.mark.usefixtures("proc_mock")
def test_command_not_found_and_no_config(tmp_path: Path) -> None:
cmd = "gm"
proc_mock.should_fail_on([cmd])
result = invoke(["deploy", "--command", cmd])
result = invoke(["deploy", "--command", cmd], cwd=tmp_path)
assert result.exit_code != 0
verify(result.output)


def test_command_not_executable(proc_mock: ProcMock) -> None:
def test_command_not_executable(proc_mock: ProcMock, tmp_path: Path, which_mock: WhichMock) -> None:
cmd = "gm"
proc_mock.should_deny_on([cmd])
result = invoke(["deploy", "--command", cmd])
cmd_resolved = which_mock.add(cmd)
proc_mock.should_deny_on([cmd_resolved])
result = invoke(["deploy", "--command", cmd], cwd=tmp_path)
assert result.exit_code != 0
verify(result.output)


def test_command_bad_exit_code(proc_mock: ProcMock) -> None:
def test_command_bad_exit_code(proc_mock: ProcMock, tmp_path: Path, which_mock: WhichMock) -> None:
cmd = "gm"
proc_mock.should_bad_exit_on([cmd], output=["it is not morning"])
result = invoke(["deploy", "--command", cmd])
cmd_resolved = which_mock.add(cmd)
proc_mock.should_bad_exit_on([cmd_resolved], output=["it is not morning"])
result = invoke(["deploy", "--command", cmd], cwd=tmp_path)
assert result.exit_code != 0
verify(result.output)


def test_algokit_env_name_missing(tmp_path_factory: TempPathFactory) -> None:
def test_algokit_env_name_missing(tmp_path_factory: TempPathFactory, which_mock: WhichMock) -> None:
config_with_override = """
[deploy.localnet]
command = "command_a"
Expand All @@ -173,14 +183,15 @@ def test_algokit_env_name_missing(tmp_path_factory: TempPathFactory) -> None:
(cwd / ALGOKIT_CONFIG).write_text(config_with_override, encoding="utf-8")
(cwd / ".env").touch()

which_mock.add("command_a")
result = invoke(["deploy", "localnet"], cwd=cwd)

assert result.exit_code == 1
verify(result.output)


def test_algokit_env_and_name_correct_set(
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, monkeypatch: pytest.MonkeyPatch
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, monkeypatch: pytest.MonkeyPatch, which_mock: WhichMock
) -> None:
env_config = """
ENV_A=GENERIC_ENV_A
Expand Down Expand Up @@ -208,7 +219,8 @@ def test_algokit_env_and_name_correct_set(
(cwd / ".env").write_text(env_config, encoding="utf-8")
(cwd / ".env.localnet").write_text(env_name_config, encoding="utf-8")

proc_mock.set_output(["command_b"], ["picked localnet"])
cmd_resolved = which_mock.add("command_b")
proc_mock.set_output([cmd_resolved], ["picked localnet"])

result = invoke(["deploy", "localnet"], cwd=cwd)

Expand All @@ -222,7 +234,9 @@ def test_algokit_env_and_name_correct_set(
verify(result.output)


def test_algokit_deploy_only_base_deploy_config(tmp_path_factory: TempPathFactory, proc_mock: ProcMock) -> None:
def test_algokit_deploy_only_base_deploy_config(
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock
) -> None:
config_with_only_base_deploy = """
[deploy]
command = "command_a"
Expand All @@ -236,7 +250,8 @@ def test_algokit_deploy_only_base_deploy_config(tmp_path_factory: TempPathFactor
(cwd / ALGOKIT_CONFIG).write_text(config_with_only_base_deploy, encoding="utf-8")
(cwd / ".env").write_text(env_config, encoding="utf-8")

proc_mock.set_output(["command_a"], ["picked base deploy command"])
cmd_resolved = which_mock.add("command_a")
proc_mock.set_output([cmd_resolved], ["picked base deploy command"])

result = invoke(["deploy"], cwd=cwd)

Expand All @@ -250,10 +265,13 @@ def test_algokit_deploy_only_base_deploy_config(tmp_path_factory: TempPathFactor


def test_ci_flag_interactivity_mode_via_env(
tmp_path_factory: TempPathFactory, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch, proc_mock: ProcMock
tmp_path_factory: TempPathFactory,
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
proc_mock: ProcMock,
which_mock: WhichMock,
) -> None:
monkeypatch.setenv("CI", "true")
cwd = tmp_path_factory.mktemp("cwd")

mock_prompt = mocker.patch("click.prompt")

Expand All @@ -269,7 +287,8 @@ def test_ci_flag_interactivity_mode_via_env(
(cwd / ALGOKIT_CONFIG).write_text(config_with_only_base_deploy, encoding="utf-8")
(cwd / ".env").touch()

proc_mock.set_output(["command_a"], ["picked base deploy command"])
cmd_resolved = which_mock.add("command_a")
proc_mock.set_output([cmd_resolved], ["picked base deploy command"])

result = invoke(["deploy"], cwd=cwd)

Expand All @@ -280,10 +299,11 @@ def test_ci_flag_interactivity_mode_via_env(


def test_ci_flag_interactivity_mode_via_cli(
tmp_path_factory: TempPathFactory, mocker: MockerFixture, proc_mock: ProcMock
tmp_path_factory: TempPathFactory,
mocker: MockerFixture,
proc_mock: ProcMock,
which_mock: WhichMock,
) -> None:
cwd = tmp_path_factory.mktemp("cwd")

mock_prompt = mocker.patch("click.prompt")

config_with_only_base_deploy = """
Expand All @@ -298,7 +318,8 @@ def test_ci_flag_interactivity_mode_via_cli(
(cwd / ALGOKIT_CONFIG).write_text(config_with_only_base_deploy, encoding="utf-8")
(cwd / ".env").touch()

proc_mock.set_output(["command_a"], ["picked base deploy command"])
cmd_resolved = which_mock.add("command_a")
proc_mock.set_output([cmd_resolved], ["picked base deploy command"])

result = invoke(["deploy", "--ci"], cwd=cwd)

Expand All @@ -310,14 +331,16 @@ def test_ci_flag_interactivity_mode_via_cli(

# environment_secrets set
def test_secrets_prompting_via_stdin(
tmp_path_factory: TempPathFactory, mocker: MockerFixture, proc_mock: ProcMock, monkeypatch: pytest.MonkeyPatch
tmp_path_factory: TempPathFactory,
mocker: MockerFixture,
proc_mock: ProcMock,
monkeypatch: pytest.MonkeyPatch,
which_mock: WhichMock,
) -> None:
# ensure Github Actions CI env var is not overriding behavior
monkeypatch.delenv("CI", raising=False)

# mock click.prompt
cwd = tmp_path_factory.mktemp("cwd")

mock_prompt = mocker.patch("click.prompt", return_value="secret_value")
config_with_only_base_deploy = """
[deploy]
Expand All @@ -330,7 +353,8 @@ def test_secrets_prompting_via_stdin(
cwd = tmp_path_factory.mktemp("cwd")
(cwd / ALGOKIT_CONFIG).write_text(config_with_only_base_deploy, encoding="utf-8")
(cwd / ".env").touch()
proc_mock.set_output(["command_a"], ["picked base deploy command"])
cmd_resolved = which_mock.add("command_a")
proc_mock.set_output([cmd_resolved], ["picked base deploy command"])

result = invoke(["deploy"], cwd=cwd)
mock_prompt.assert_called_once() # ensure called
Expand All @@ -346,8 +370,7 @@ def test_secrets_prompting_via_stdin(


def test_deploy_custom_project_dir(
tmp_path_factory: TempPathFactory,
proc_mock: ProcMock,
tmp_path_factory: TempPathFactory, proc_mock: ProcMock, which_mock: WhichMock
) -> None:
cwd = tmp_path_factory.mktemp("cwd")
custom_folder = cwd / "custom_folder"
Expand All @@ -361,7 +384,8 @@ def test_deploy_custom_project_dir(
encoding="utf-8",
)
(custom_folder / ".env.testnet").touch()
proc_mock.set_output(["command_a"], ["picked base deploy command"])
cmd_resolved = which_mock.add("command_a")
proc_mock.set_output([cmd_resolved], ["picked base deploy command"])

input_answers = ["N"]

Expand All @@ -372,3 +396,21 @@ def test_deploy_custom_project_dir(

assert result.exit_code == 0
verify(result.output)


def test_deploy_shutil_command_not_found(tmp_path_factory: TempPathFactory) -> None:
cwd = tmp_path_factory.mktemp("cwd")

(cwd / ALGOKIT_CONFIG).write_text(
"""
[deploy]
command = "command_a"
""".strip(),
encoding="utf-8",
)
(cwd / ".env").touch()

result = invoke("deploy", cwd=cwd)

assert result.exit_code == 1
verify(result.output)
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
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: command_a
Using deploy command: /bin/command_a
Loading deployment environment variables...
Deploying smart contracts from AlgoKit compliant repository 🚀
DEBUG: Running 'command_a' in '{current_working_directory}'
command_a: picked localnet
DEBUG: Running '/bin/command_a' in '{current_working_directory}'
/bin/command_a: picked localnet
Loading

1 comment on commit 17791c7

@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
   deploy.py56591%34–36, 78, 96
   doctor.py48394%142–144
   generate.py30197%49
   goal.py30197%42
   init.py1901692%268–269, 319, 322–324, 335, 379, 405, 445, 454–456, 459–464, 477
   localnet.py91397%157, 178–179
src/algokit/core
   bootstrap.py1612485%103–104, 126, 149, 214, 217, 223–237, 246–251
   conf.py54885%10, 24, 28, 36, 38, 71–73
   deploy.py691184%61–64, 73–75, 79, 84, 91–93
   doctor.py65789%67–69, 92–94, 134
   log_handlers.py68790%50–51, 63, 112–116, 125
   proc.py45198%98
   sandbox.py1501590%100–107, 118, 226, 242, 257–259, 275
   typed_client_generation.py80594%55–57, 70, 75
   version_prompt.py73889%27–28, 40, 59–62, 80, 109
TOTAL149612692% 

Tests Skipped Failures Errors Time
233 0 💤 0 ❌ 0 🔥 14.288s ⏱️

Please sign in to comment.