diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d1bb195..91c4477 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,8 +51,8 @@ repos: rev: v2.9.0 hooks: - id: pip-audit - # Temporary workaround: ignoring pip vulnerability GHSA-4xh5-x5gv-qwph (pip 25.2). - # Remove this ignore once a patched version of pip is available. + # Ensure the hook environment uses a patched pip version. + additional_dependencies: ["pip>=26.1"] args: [--format=json, --ignore-vuln=GHSA-4xh5-x5gv-qwph] # Local hooks for project-specific tasks diff --git a/Makefile b/Makefile index 53dae42..1a6c21e 100644 --- a/Makefile +++ b/Makefile @@ -73,8 +73,8 @@ clean: rm -rf *.egg-info/ rm -rf .coverage rm -rf htmlcov/ - find . -type d -name __pycache__ -delete find . -type f -name "*.pyc" -delete + find . -type d -name __pycache__ -delete build: clean python -m build diff --git a/README.md b/README.md index ccf08bc..01fcdc0 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ workato connections list # Check project status workato workspace + +# Configure project export defaults +workato projects config --include-tags --no-include-test-cases + +# Show current project export defaults +workato projects config show ``` ## Next Steps diff --git a/docs/COMMAND_REFERENCE.md b/docs/COMMAND_REFERENCE.md index 498f9cb..6865f86 100644 --- a/docs/COMMAND_REFERENCE.md +++ b/docs/COMMAND_REFERENCE.md @@ -23,6 +23,8 @@ workato init # Initialize CLI configuration workato workspace # Show current workspace info workato pull # Pull latest from remote workato push [--restart-recipes] # Push local changes (recipes won't restart by default) +workato projects config --include-tags|--no-include-tags [--include-test-cases|--no-include-test-cases] +workato projects config show [--output-mode table|json] ``` ### Recipe Management diff --git a/src/workato_platform_cli/cli/commands/projects/command.py b/src/workato_platform_cli/cli/commands/projects/command.py index e08f3f7..7e5d71c 100644 --- a/src/workato_platform_cli/cli/commands/projects/command.py +++ b/src/workato_platform_cli/cli/commands/projects/command.py @@ -286,6 +286,85 @@ async def switch( click.echo(f"❌ Failed to switch to project '{selected_project_name}': {e}") +@projects.group(name="config", invoke_without_command=True) +@click.option( + "--include-tags/--no-include-tags", + default=None, + help=( + "Set export_include_tags default in .workatoenv for project export manifests" + ), +) +@click.option( + "--include-test-cases/--no-include-test-cases", + default=None, + help=( + "Set export_include_test_cases default in .workatoenv for project " + "export manifests" + ), +) +@handle_cli_exceptions +@inject +async def set_project_config( + include_tags: bool | None = None, + include_test_cases: bool | None = None, + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Set project export defaults in local .workatoenv config.""" + + if include_tags is None and include_test_cases is None: + click.echo("❌ No config values provided") + click.echo( + "💡 Use --include-tags/--no-include-tags and/or " + "--include-test-cases/--no-include-test-cases" + ) + return + + config_data = config_manager.load_config() + + if include_tags is not None: + config_data.export_include_tags = include_tags + if include_test_cases is not None: + config_data.export_include_test_cases = include_test_cases + + config_manager.save_config(config_data) + + click.echo("✅ Updated project export defaults") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + +@set_project_config.command(name="show") +@click.option( + "--output-mode", + type=click.Choice(["table", "json"]), + default="table", + help="Output format: table (default) or json", +) +@handle_cli_exceptions +@inject +async def show_project_config( + output_mode: str = "table", + config_manager: ConfigManager = Provide[Container.config_manager], +) -> None: + """Show project export defaults from local .workatoenv config.""" + config_data = config_manager.load_config() + + if output_mode == "json": + click.echo( + json.dumps( + { + "export_include_tags": config_data.export_include_tags, + "export_include_test_cases": config_data.export_include_test_cases, + } + ) + ) + return + + click.echo("📋 Project export defaults:") + click.echo(f" export_include_tags: {config_data.export_include_tags}") + click.echo(f" export_include_test_cases: {config_data.export_include_test_cases}") + + async def _get_local_projects( config_manager: ConfigManager, ) -> list[tuple[Any, str, ConfigData | None]]: diff --git a/src/workato_platform_cli/cli/commands/projects/project_manager.py b/src/workato_platform_cli/cli/commands/projects/project_manager.py index 2a3e84b..33c3dd2 100644 --- a/src/workato_platform_cli/cli/commands/projects/project_manager.py +++ b/src/workato_platform_cli/cli/commands/projects/project_manager.py @@ -104,13 +104,80 @@ async def check_folder_assets(self, folder_id: int) -> list[Asset]: ) return assets + @staticmethod + def _parse_bool_value(value: str | None) -> bool | None: + """Parse common string boolean formats to bool.""" + if value is None: + return None + + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return None + + def _resolve_export_manifest_flags( + self, + include_tags: bool | None, + include_test_cases: bool | None, + ) -> tuple[bool, bool]: + """Resolve export manifest flags.""" + config_include_tags: bool | None = None + config_include_test_cases: bool | None = None + + try: + from workato_platform_cli.cli.utils.config import ConfigManager + + config_data = ConfigManager(skip_validation=True).load_config() + config_include_tags = config_data.export_include_tags + config_include_test_cases = config_data.export_include_test_cases + except Exception: + # Export should still work even if local config cannot be loaded. + click.echo("⚠️ Could not load config, using defaults") + pass + + env_include_tags = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TAGS") + ) + env_include_test_cases = self._parse_bool_value( + os.environ.get("WORKATO_INCLUDE_TEST_CASES") + ) + + resolved_include_tags = ( + include_tags + if include_tags is not None + else config_include_tags + if config_include_tags is not None + else env_include_tags + if env_include_tags is not None + else False + ) + resolved_include_test_cases = ( + include_test_cases + if include_test_cases is not None + else config_include_test_cases + if config_include_test_cases is not None + else env_include_test_cases + if env_include_test_cases is not None + else False + ) + + return resolved_include_tags, resolved_include_test_cases + async def export_project( self, folder_id: int, project_name: str, target_dir: str = "project", + include_tags: bool | None = None, + include_test_cases: bool | None = None, ) -> str | None: """Export project assets and return project directory path""" + resolved_include_tags, resolved_include_test_cases = ( + self._resolve_export_manifest_flags(include_tags, include_test_cases) + ) + # Check if project has any assets before attempting export assets = await self.check_folder_assets(folder_id) @@ -131,6 +198,8 @@ async def export_project( name=project_name, folder_id=folder_id, auto_generate_assets=True, + include_tags=resolved_include_tags, + include_test_cases=resolved_include_test_cases, ) ) ) diff --git a/src/workato_platform_cli/cli/utils/config/models.py b/src/workato_platform_cli/cli/utils/config/models.py index 923ddfb..f4420a7 100644 --- a/src/workato_platform_cli/cli/utils/config/models.py +++ b/src/workato_platform_cli/cli/utils/config/models.py @@ -21,6 +21,16 @@ class ConfigData(BaseModel): ) folder_id: int | None = Field(None, description="Folder ID") profile: str | None = Field(None, description="Profile override") + export_include_tags: bool | None = Field( + None, + description="Default value for include_tags when exporting project manifests", + ) + export_include_test_cases: bool | None = Field( + None, + description=( + "Default value for include_test_cases when exporting project manifests" + ), + ) class RegionInfo(BaseModel): diff --git a/src/workato_platform_cli/client/workato_api/models/api_client.py b/src/workato_platform_cli/client/workato_api/models/api_client.py index 1cbabf4..0cf9cb9 100644 --- a/src/workato_platform_cli/client/workato_api/models/api_client.py +++ b/src/workato_platform_cli/client/workato_api/models/api_client.py @@ -45,8 +45,8 @@ class ApiClient(BaseModel): mtls_enabled: Optional[StrictBool] = None validation_formula: Optional[StrictStr] = None cert_bundle_ids: Optional[List[StrictInt]] = None - api_policies: Optional[List[ApiClientApiPoliciesInner]] = Field(default=None, description="List of API policies associated with the client") - api_collections: Optional[List[ApiClientApiCollectionsInner]] = Field(default=None, description="List of API collections associated with the client") + api_policies: List[ApiClientApiPoliciesInner] = Field(description="List of API policies associated with the client") + api_collections: List[ApiClientApiCollectionsInner] = Field(description="List of API collections associated with the client") __properties: ClassVar[List[str]] = ["id", "name", "description", "active_api_keys_count", "total_api_keys_count", "created_at", "updated_at", "logo", "logo_2x", "is_legacy", "email", "auth_type", "api_token", "mtls_enabled", "validation_formula", "cert_bundle_ids", "api_policies", "api_collections"] @field_validator('auth_type') diff --git a/src/workato_platform_cli/client/workato_api/models/api_collection.py b/src/workato_platform_cli/client/workato_api/models/api_collection.py index bc3a644..0774649 100644 --- a/src/workato_platform_cli/client/workato_api/models/api_collection.py +++ b/src/workato_platform_cli/client/workato_api/models/api_collection.py @@ -18,8 +18,8 @@ import json from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, field_validator -from typing import Any, ClassVar, Dict, List, Optional, Union +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional from workato_platform_cli.client.workato_api.models.import_results import ImportResults from typing import Optional, Set from typing_extensions import Self @@ -30,7 +30,7 @@ class ApiCollection(BaseModel): """ # noqa: E501 id: StrictInt name: StrictStr - project_id: Optional[Union[StrictStr, StrictInt]] = None + project_id: StrictStr url: StrictStr api_spec_url: StrictStr version: StrictStr @@ -40,14 +40,6 @@ class ApiCollection(BaseModel): import_results: Optional[ImportResults] = None __properties: ClassVar[List[str]] = ["id", "name", "project_id", "url", "api_spec_url", "version", "created_at", "updated_at", "message", "import_results"] - @field_validator('project_id', mode='before') - @classmethod - def coerce_project_id_to_string(cls, v): - """Coerce project_id to string since API may return int or string""" - if v is not None: - return str(v) - return v - model_config = ConfigDict( populate_by_name=True, validate_assignment=True, diff --git a/tests/unit/commands/projects/test_command.py b/tests/unit/commands/projects/test_command.py index 4f81454..883699d 100644 --- a/tests/unit/commands/projects/test_command.py +++ b/tests/unit/commands/projects/test_command.py @@ -1138,3 +1138,86 @@ async def mock_aexit(_self: Any, *_args: Any) -> None: mock_create_config.assert_called_once_with( config_manager=config_manager, cli_profile="test-profile" ) + + +@pytest.mark.asyncio +async def test_projects_config_sets_include_defaults(capture_echo: list[str]) -> None: + """Projects config command should persist include defaults.""" + config_manager = Mock() + config_data = ConfigData() + config_manager.load_config.return_value = config_data + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=True, + include_test_cases=False, + config_manager=config_manager, + ) + + saved_config = config_manager.save_config.call_args.args[0] + assert saved_config.export_include_tags is True + assert saved_config.export_include_test_cases is False + output = "\n".join(capture_echo) + assert "Updated project export defaults" in output + + +@pytest.mark.asyncio +async def test_projects_config_requires_at_least_one_value( + capture_echo: list[str], +) -> None: + """Projects config command should reject empty updates.""" + config_manager = Mock() + config_manager.load_config = Mock() + config_manager.save_config = Mock() + + await command.set_project_config.callback( # type: ignore[misc] + # ctx=Mock(invoked_subcommand=None), + include_tags=None, + include_test_cases=None, + config_manager=config_manager, + ) + + config_manager.load_config.assert_not_called() + config_manager.save_config.assert_not_called() + output = "\n".join(capture_echo) + assert "No config values provided" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_table(capture_echo: list[str]) -> None: + """Projects config show should print table output by default.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=True, + export_include_test_cases=False, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="table", + config_manager=config_manager, + ) + + output = "\n".join(capture_echo) + assert "Project export defaults" in output + assert "export_include_tags: True" in output + assert "export_include_test_cases: False" in output + + +@pytest.mark.asyncio +async def test_projects_config_show_outputs_json(capture_echo: list[str]) -> None: + """Projects config show should support JSON output.""" + config_manager = Mock() + config_manager.load_config.return_value = ConfigData( + export_include_tags=False, + export_include_test_cases=True, + ) + + await command.show_project_config.callback( # type: ignore[misc] + output_mode="json", + config_manager=config_manager, + ) + + data = json.loads("".join(capture_echo)) + assert data["export_include_tags"] is False + assert data["export_include_test_cases"] is True diff --git a/tests/unit/commands/projects/test_project_manager.py b/tests/unit/commands/projects/test_project_manager.py index 89fa960..0e1f59e 100644 --- a/tests/unit/commands/projects/test_project_manager.py +++ b/tests/unit/commands/projects/test_project_manager.py @@ -11,6 +11,7 @@ import pytest from workato_platform_cli.cli.commands.projects.project_manager import ProjectManager +from workato_platform_cli.cli.utils.config import ConfigData from workato_platform_cli.client.workato_api.models.project import Project @@ -225,6 +226,121 @@ async def test_export_project_happy_path( assert result == str(project_dir) +@pytest.mark.asyncio +async def test_export_project_uses_config_defaults_for_manifest_flags( + project_manager: ProjectManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Mock(result=Mock(id=88)) + project_dir = tmp_path / "extracted" + + class StubConfigManager: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def load_config(self) -> ConfigData: + return ConfigData( + export_include_tags=True, + export_include_test_cases=False, + ) + + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.ConfigManager", + StubConfigManager, + ) + + with ( + patch.object( + project_manager, "check_folder_assets", AsyncMock(return_value=[Mock()]) + ), + patch.object( + project_manager.client.export_api, + "create_export_manifest", + AsyncMock(return_value=manifest), + ) as mock_create_manifest, + patch.object( + project_manager.client.packages_api, + "export_package", + AsyncMock(return_value=Mock(id=44)), + ), + patch.object( + project_manager, + "download_and_extract_package", + AsyncMock(return_value=project_dir), + ), + ): + await project_manager.export_project( + folder_id=9, + project_name="Demo", + target_dir=str(project_dir), + ) + + assert mock_create_manifest.await_args is not None + request = mock_create_manifest.await_args.kwargs[ + "create_export_manifest_request" + ] + assert request.export_manifest.include_tags is True + assert request.export_manifest.include_test_cases is False + + +@pytest.mark.asyncio +async def test_export_project_defaults_manifest_flags_to_false( + project_manager: ProjectManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manifest = Mock(result=Mock(id=88)) + project_dir = tmp_path / "extracted" + + class StubConfigManager: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def load_config(self) -> ConfigData: + return ConfigData() + + monkeypatch.setattr( + "workato_platform_cli.cli.utils.config.ConfigManager", + StubConfigManager, + ) + monkeypatch.delenv("WORKATO_INCLUDE_TAGS", raising=False) + monkeypatch.delenv("WORKATO_INCLUDE_TEST_CASES", raising=False) + + with ( + patch.object( + project_manager, "check_folder_assets", AsyncMock(return_value=[Mock()]) + ), + patch.object( + project_manager.client.export_api, + "create_export_manifest", + AsyncMock(return_value=manifest), + ) as mock_create_manifest, + patch.object( + project_manager.client.packages_api, + "export_package", + AsyncMock(return_value=Mock(id=44)), + ), + patch.object( + project_manager, + "download_and_extract_package", + AsyncMock(return_value=project_dir), + ), + ): + await project_manager.export_project( + folder_id=9, + project_name="Demo", + target_dir=str(project_dir), + ) + + assert mock_create_manifest.await_args is not None + request = mock_create_manifest.await_args.kwargs[ + "create_export_manifest_request" + ] + assert request.export_manifest.include_tags is False + assert request.export_manifest.include_test_cases is False + + @pytest.mark.asyncio async def test_download_and_extract_package_success( tmp_path: Path, monkeypatch: pytest.MonkeyPatch