Skip to content
Merged
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: 6 additions & 0 deletions src/workato_platform_cli/cli/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
default="table",
help="Output format: table (default) or json (only with --non-interactive)",
)
@click.option(
"--folder-name",
help="Custom folder name for the project (defaults to project name)",
)
@handle_cli_exceptions
@handle_api_exceptions
async def init(
Expand All @@ -54,6 +58,7 @@ async def init(
project_id: int | None = None,
non_interactive: bool = False,
output_mode: str = "table",
folder_name: str | None = None,
) -> None:
"""Initialize Workato CLI for a new project

Expand Down Expand Up @@ -156,6 +161,7 @@ async def init(
project_id=project_id,
output_mode=output_mode,
non_interactive=non_interactive,
folder_name=folder_name,
)

# Check if project directory exists and is non-empty
Expand Down
31 changes: 25 additions & 6 deletions src/workato_platform_cli/cli/utils/config/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ async def initialize(
project_id: int | None = None,
output_mode: str = "table",
non_interactive: bool = False,
folder_name: str | None = None,
) -> "ConfigManager":
"""Initialize workspace with interactive or non-interactive setup"""
if output_mode == "table":
Expand All @@ -80,14 +81,15 @@ async def initialize(
api_url=api_url,
project_name=project_name,
project_id=project_id,
folder_name=folder_name,
)
else:
# Run setup flow
await manager._run_setup_flow()
await manager._run_setup_flow(folder_name=folder_name)

return manager

async def _run_setup_flow(self) -> None:
async def _run_setup_flow(self, folder_name: str | None = None) -> None:
"""Run the complete setup flow"""
workspace_root = self.workspace_manager.find_workspace_root()
self.config_dir = workspace_root
Expand All @@ -99,7 +101,7 @@ async def _run_setup_flow(self) -> None:
profile_name = await self._setup_profile()

# Step 2: Project setup
await self._setup_project(profile_name, workspace_root)
await self._setup_project(profile_name, workspace_root, folder_name)

# Step 3: Create workspace files
self._create_workspace_files(workspace_root)
Expand All @@ -114,6 +116,7 @@ async def _setup_non_interactive(
api_url: str | None = None,
project_name: str | None = None,
project_id: int | None = None,
folder_name: str | None = None,
) -> None:
"""Perform all setup actions non-interactively"""

Expand Down Expand Up @@ -238,7 +241,9 @@ async def _setup_non_interactive(

# Project doesn't exist locally - create new directory
current_dir = Path.cwd().resolve()
project_path = current_dir / selected_project.name
# Use custom folder name if provided, otherwise use project name
folder_name = folder_name if folder_name else selected_project.name
project_path = current_dir / folder_name

# Create project directory
project_path.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -552,7 +557,9 @@ async def _create_new_profile(self, profile_name: str) -> None:
# Save profile and token
self.profile_manager.set_profile(profile_name, profile_data, token)

async def _setup_project(self, profile_name: str, workspace_root: Path) -> None:
async def _setup_project(
self, profile_name: str, workspace_root: Path, folder_name: str | None = None
) -> None:
"""Setup project interactively"""
click.echo("📁 Step 2: Setup project")

Expand Down Expand Up @@ -607,6 +614,16 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None:
if not selected_project:
raise click.ClickException("No project selected")

# Prompt for custom folder name if not provided via CLI
if folder_name is None:
click.echo()
custom_name = await click.prompt(
"Folder name (leave blank for default)",
default=selected_project.name,
type=str,
)
folder_name = custom_name.strip() if custom_name.strip() else None

# Check if this specific project already exists locally in the workspace
local_projects = self._find_all_projects(workspace_root)
existing_local_path = None
Expand Down Expand Up @@ -641,7 +658,9 @@ async def _setup_project(self, profile_name: str, workspace_root: Path) -> None:
else:
# Project doesn't exist locally - create new directory
current_dir = Path.cwd().resolve()
project_path = current_dir / selected_project.name
# Use custom folder name if provided, otherwise use project name
folder_name = folder_name if folder_name else selected_project.name
project_path = current_dir / folder_name

# Validate project path
try:
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/commands/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Shared test fixtures for command tests."""

from collections.abc import Callable
from unittest.mock import AsyncMock, Mock

import pytest


@pytest.fixture
def mock_init_dependencies(
monkeypatch: pytest.MonkeyPatch,
) -> Callable[..., dict[str, Mock | AsyncMock]]:
"""Setup common init command dependencies and mocks.

Returns a factory that creates and patches all common init test dependencies.

Usage:
mocks = mock_init_dependencies(profile="test-profile", token="test-token")
# mocks contains: initialize_mock, pull_mock
"""

def _factory(
profile: str = "default",
token: str = "test-token", # noqa: S107
) -> dict[str, Mock | AsyncMock]:
from workato_platform_cli.cli.commands import init as init_module

# Create mocks
mock_config_manager = Mock()
mock_workato_client = Mock()
workato_context = AsyncMock()

# Setup config manager defaults
mock_config_manager.load_config.return_value = Mock(profile=profile)
mock_config_manager.get_project_directory.return_value = None
resolve_env = mock_config_manager.profile_manager.resolve_environment_variables
resolve_env.return_value = (token, "https://api.workato.com")

# Setup Workato context
workato_context.__aenter__.return_value = mock_workato_client
workato_context.__aexit__.return_value = False

# Create and patch initialize mock
mock_initialize = AsyncMock(return_value=mock_config_manager)
monkeypatch.setattr(
init_module.ConfigManager,
"initialize",
mock_initialize,
)

# Create and patch pull mock
mock_pull = AsyncMock()
monkeypatch.setattr(init_module, "_pull_project", mock_pull)

# Patch Workato (Configuration doesn't need mocking)
monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context)

# Silence click.echo
monkeypatch.setattr(init_module.click, "echo", lambda _="": None)

return {
"initialize_mock": mock_initialize,
"pull_mock": mock_pull,
}

return _factory
160 changes: 109 additions & 51 deletions tests/unit/commands/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json

from collections.abc import Callable
from io import StringIO
from pathlib import Path
from unittest.mock import AsyncMock, Mock, patch
Expand All @@ -13,59 +14,28 @@


@pytest.mark.asyncio
async def test_init_interactive_mode(monkeypatch: pytest.MonkeyPatch) -> None:
async def test_init_interactive_mode(
mock_init_dependencies: Callable[..., dict[str, Mock | AsyncMock]],
) -> None:
"""Test interactive mode (default behavior)."""
mock_config_manager = Mock()
mock_workato_client = Mock()
workato_context = AsyncMock()
mocks = mock_init_dependencies(profile="default", token="token")

with (
patch.object(
mock_config_manager, "load_config", return_value=Mock(profile="default")
),
patch.object(
mock_config_manager,
"get_project_directory",
return_value=None,
),
patch.object(
mock_config_manager.profile_manager,
"resolve_environment_variables",
return_value=("token", "https://api.workato.com"),
),
patch.object(workato_context, "__aenter__", return_value=mock_workato_client),
patch.object(workato_context, "__aexit__", return_value=False),
):
mock_initialize = AsyncMock(return_value=mock_config_manager)
monkeypatch.setattr(
init_module.ConfigManager,
"initialize",
mock_initialize,
)

mock_pull = AsyncMock()
monkeypatch.setattr(init_module, "_pull_project", mock_pull)

monkeypatch.setattr(init_module, "Workato", lambda **_: workato_context)
monkeypatch.setattr(init_module, "Configuration", lambda **_: Mock())

monkeypatch.setattr(init_module.click, "echo", lambda _="": None)

assert init_module.init.callback
await init_module.init.callback()

# Should call initialize with no parameters (interactive mode)
mock_initialize.assert_awaited_once_with(
profile_name=None,
region=None,
api_token=None,
api_url=None,
project_name=None,
project_id=None,
output_mode="table",
non_interactive=False,
)
mock_pull.assert_awaited_once()
assert init_module.init.callback
await init_module.init.callback()

# Should call initialize with no parameters (interactive mode)
mocks["initialize_mock"].assert_awaited_once_with(
profile_name=None,
region=None,
api_token=None,
api_url=None,
project_name=None,
project_id=None,
output_mode="table",
non_interactive=False,
folder_name=None,
)
mocks["pull_mock"].assert_awaited_once()


@pytest.mark.asyncio
Expand Down Expand Up @@ -137,6 +107,7 @@ async def test_init_non_interactive_success(monkeypatch: pytest.MonkeyPatch) ->
project_id=None,
output_mode="table",
non_interactive=True,
folder_name=None,
)
mock_pull.assert_awaited_once()

Expand Down Expand Up @@ -205,6 +176,7 @@ async def test_init_non_interactive_custom_region(
project_id=123,
output_mode="table",
non_interactive=True,
folder_name=None,
)


Expand Down Expand Up @@ -369,6 +341,7 @@ async def test_init_non_interactive_with_region_and_token(
project_id=None,
output_mode="table",
non_interactive=True,
folder_name=None,
)


Expand Down Expand Up @@ -1553,3 +1526,88 @@ async def test_init_non_interactive_new_profile_with_region_and_token(

# Should proceed without error and not check credentials
mock_pull.assert_awaited_once()


@pytest.mark.asyncio
async def test_init_with_custom_folder_name_non_interactive(
mock_init_dependencies: Callable[..., dict[str, Mock | AsyncMock]],
) -> None:
"""Test non-interactive mode with custom folder name."""
mocks = mock_init_dependencies(profile="test-profile", token="test-token")

assert init_module.init.callback
await init_module.init.callback(
profile="test-profile",
project_name="test-project",
non_interactive=True,
folder_name="custom-folder",
)

# Should call initialize with custom folder name
mocks["initialize_mock"].assert_awaited_once_with(
profile_name="test-profile",
region=None,
api_token=None,
api_url=None,
project_name="test-project",
project_id=None,
output_mode="table",
non_interactive=True,
folder_name="custom-folder",
)
mocks["pull_mock"].assert_awaited_once()


@pytest.mark.asyncio
async def test_init_with_empty_folder_name_non_interactive(
mock_init_dependencies: Callable[..., dict[str, Mock | AsyncMock]],
) -> None:
"""Test non-interactive mode with empty string folder_name falls back to None."""
mocks = mock_init_dependencies(profile="test-profile", token="test-token")

assert init_module.init.callback
await init_module.init.callback(
profile="test-profile",
project_name="test-project",
non_interactive=True,
folder_name="", # Empty string should be treated as None
)

# Should call initialize with folder_name="" (empty string is passed through)
mocks["initialize_mock"].assert_awaited_once_with(
profile_name="test-profile",
region=None,
api_token=None,
api_url=None,
project_name="test-project",
project_id=None,
output_mode="table",
non_interactive=True,
folder_name="", # Empty string is passed as-is to ConfigManager.initialize
)
mocks["pull_mock"].assert_awaited_once()


@pytest.mark.asyncio
async def test_init_with_custom_folder_name_interactive(
mock_init_dependencies: Callable[..., dict[str, Mock | AsyncMock]],
) -> None:
"""Test interactive mode with custom folder name."""
mocks = mock_init_dependencies(profile="default", token="token")

assert init_module.init.callback
await init_module.init.callback(folder_name="my-custom-folder")

# Should call initialize with custom folder name
mocks["initialize_mock"].assert_awaited_once_with(
profile_name=None,
region=None,
api_token=None,
api_url=None,
project_name=None,
project_id=None,
output_mode="table",
non_interactive=False,
folder_name="my-custom-folder",
)
mocks["pull_mock"].assert_awaited_once()
Loading