diff --git a/src/ansible_creator/arg_parser.py b/src/ansible_creator/arg_parser.py index e9541688..c026da98 100644 --- a/src/ansible_creator/arg_parser.py +++ b/src/ansible_creator/arg_parser.py @@ -36,7 +36,6 @@ COMING_SOON = ( "add resource devcontainer", - "add resource devfile", "add resource role", "add plugin action", "add plugin filter", @@ -199,22 +198,7 @@ def _add_args_init_common(self, parser: ArgumentParser) -> None: "This flag is deprecated and will be removed soon." ), ) - parser.add_argument( - "-o", - "--overwrite", - default=False, - dest="overwrite", - action="store_true", - help="Overwrite existing files or directories.", - ) - parser.add_argument( - "-no", - "--no-overwrite", - default=False, - dest="no_overwrite", - action="store_true", - help="Flag that restricts overwriting operation.", - ) + self._add_overwrite(parser) def _add_args_plugin_common(self, parser: ArgumentParser) -> None: """Add common plugin arguments to the parser. @@ -293,6 +277,8 @@ def _add_resource_devfile(self: Parser, subparser: SubParser[ArgumentParser]) -> help="The destination directory for the devfile file. The default is the " "current working directory.", ) + + self._add_overwrite(parser) self._add_args_common(parser) def _add_resource_role(self: Parser, subparser: SubParser[ArgumentParser]) -> None: @@ -382,6 +368,29 @@ def _add_plugin_lookup(self: Parser, subparser: SubParser[ArgumentParser]) -> No self._add_args_common(parser) self._add_args_plugin_common(parser) + def _add_overwrite(self, parser: ArgumentParser) -> None: + """Add overwrite and no-overwrite arguments to the parser. + + Args: + parser: The parser to add overwrite and no_overwrite options + """ + parser.add_argument( + "-o", + "--overwrite", + default=False, + dest="overwrite", + action="store_true", + help="Overwrite existing files or directories.", + ) + parser.add_argument( + "-no", + "--no-overwrite", + default=False, + dest="no_overwrite", + action="store_true", + help="Flag that restricts overwriting operation.", + ) + def _init(self: Parser, subparser: SubParser[ArgumentParser]) -> None: """Initialize an Ansible project. diff --git a/src/ansible_creator/config.py b/src/ansible_creator/config.py index d40d8ffd..cded3ec8 100644 --- a/src/ansible_creator/config.py +++ b/src/ansible_creator/config.py @@ -30,12 +30,14 @@ class Config: project: The type of project to scaffold. collection_name: The name of the collection. namespace: The namespace for the collection. + resource_type: The type of resource to be scaffolded. + type: The type of the project for which the resource is being scaffolded. + path: The file path where the resource should be added. """ creator_version: str output: Output subcommand: str - collection: str = "" force: bool = False overwrite: bool = False @@ -44,6 +46,9 @@ class Config: project: str = "" collection_name: str | None = None namespace: str = "" + resource_type: str = "" + type: str = "" + path: str = "" def __post_init__(self: Config) -> None: """Post process config values.""" diff --git a/src/ansible_creator/subcommands/add.py b/src/ansible_creator/subcommands/add.py new file mode 100644 index 00000000..424c7415 --- /dev/null +++ b/src/ansible_creator/subcommands/add.py @@ -0,0 +1,150 @@ +"""Definitions for ansible-creator add action.""" + +from __future__ import annotations + +import uuid + +from pathlib import Path +from typing import TYPE_CHECKING + +from ansible_creator.exceptions import CreatorError +from ansible_creator.templar import Templar +from ansible_creator.types import TemplateData +from ansible_creator.utils import Copier, Walker, ask_yes_no + + +if TYPE_CHECKING: + from ansible_creator.config import Config + from ansible_creator.output import Output + + +class Add: + """Class to handle the add subcommand.""" + + def __init__( + self: Add, + config: Config, + ) -> None: + """Initialize the add action. + + Args: + config: App configuration object. + """ + self._resource_type: str = config.resource_type + self._resource_id: str = f"common.{self._resource_type}" + self._add_path: Path = Path(config.path) + self._force = config.force + self._overwrite = config.overwrite + self._no_overwrite = config.no_overwrite + self._creator_version = config.creator_version + self._project = config.project + self.output: Output = config.output + self.templar = Templar() + + def run(self) -> None: + """Start scaffolding the resource file.""" + self._check_add_path() + self.output.debug(msg=f"final collection path set to {self._add_path}") + + self._scaffold() + + def _check_add_path(self) -> None: + """Validate the provided add path. + + Raises: + CreatorError: If the add path does not exist. + """ + if not self._add_path.exists(): + msg = f"The path {self._add_path} does not exist. Please provide an existing directory." + raise CreatorError(msg) + + def unique_name_in_devfile(self) -> str: + """Use project specific name in devfile. + + Returns: + Unique name entry. + """ + final_name = ".".join(self._add_path.parts[-2:]) + final_uuid = str(uuid.uuid4())[:8] + return f"{final_name}-{final_uuid}" + + def _scaffold(self) -> None: + """Scaffold the specified resource file based on the resource type. + + Raises: + CreatorError: If unsupported resource type is given. + """ + self.output.debug(f"Started copying {self._project} resource to destination") + + # Call the appropriate scaffolding function based on the resource type + if self._resource_type == "devfile": + template_data = self._get_devfile_template_data() + + else: + + msg = f"Unsupported resource type: {self._resource_type}" + raise CreatorError(msg) + + self._perform_scaffold(template_data) + + def _perform_scaffold(self, template_data: TemplateData) -> None: + """Perform the actual scaffolding process using the provided template data. + + Args: + template_data: TemplateData + + Raises: + CreatorError: If there are conflicts and overwriting is not allowed, or if the + destination directory contains files that will be overwritten. + """ + walker = Walker( + resources=(f"common.{self._resource_type}",), + resource_id=self._resource_id, + dest=self._add_path, + output=self.output, + template_data=template_data, + templar=self.templar, + ) + paths = walker.collect_paths() + copier = Copier(output=self.output) + + if self._no_overwrite: + msg = "The flag `--no-overwrite` restricts overwriting." + if paths.has_conflicts(): + msg += ( + "\nThe destination directory contains files that can be overwritten." + "\nPlease re-run ansible-creator with --overwrite to continue." + ) + raise CreatorError(msg) + + if not paths.has_conflicts() or self._force or self._overwrite: + copier.copy_containers(paths) + self.output.note(f"Resource added to {self._add_path}") + return + + if not self._overwrite: + question = ( + "Files in the destination directory will be overwritten. Do you want to proceed?" + ) + if ask_yes_no(question): + copier.copy_containers(paths) + else: + msg = ( + "The destination directory contains files that will be overwritten." + " Please re-run ansible-creator with --overwrite to continue." + ) + raise CreatorError(msg) + + self.output.note(f"Resource added to {self._add_path}") + + def _get_devfile_template_data(self) -> TemplateData: + """Get the template data for devfile resources. + + Returns: + TemplateData: Data required for templating the devfile resource. + """ + return TemplateData( + resource_type=self._resource_type, + creator_version=self._creator_version, + dev_file_name=self.unique_name_in_devfile(), + ) diff --git a/src/ansible_creator/types.py b/src/ansible_creator/types.py index 44d05571..bbe9691d 100644 --- a/src/ansible_creator/types.py +++ b/src/ansible_creator/types.py @@ -17,6 +17,7 @@ class TemplateData: """Dataclass representing the template data. Attributes: + resource_type: The type of resource to be scaffolded. additions: A dictionary containing additional data to add to the gitignore. collection_name: The name of the collection. creator_version: The version of the creator. @@ -27,6 +28,7 @@ class TemplateData: recommended_extensions: A list of recommended VsCode extensions. """ + resource_type: str = "" additions: dict[str, dict[str, dict[str, str | bool]]] = field(default_factory=dict) collection_name: str = "" creator_version: str = "" diff --git a/tests/fixtures/common/devfile/devfile.yaml b/tests/fixtures/common/devfile/devfile.yaml new file mode 100644 index 00000000..8f934573 --- /dev/null +++ b/tests/fixtures/common/devfile/devfile.yaml @@ -0,0 +1,15 @@ +schemaVersion: 2.2.2 +metadata: + name: testorg +components: + - name: tooling-container + container: + image: ghcr.io/ansible/ansible-workspace-env-reference:latest + memoryRequest: 256M + memoryLimit: 6Gi + cpuRequest: 250m + cpuLimit: 2000m + args: ["tail", "-f", "/dev/null"] + env: + - name: KUBEDOCK_ENABLED + value: "true" diff --git a/tests/units/test_add.py b/tests/units/test_add.py new file mode 100644 index 00000000..d9890084 --- /dev/null +++ b/tests/units/test_add.py @@ -0,0 +1,268 @@ +# cspell: ignore dcmp, subdcmp +"""Unit tests for ansible-creator add.""" + +from __future__ import annotations + +import re + +from filecmp import cmp, dircmp +from typing import TYPE_CHECKING, TypedDict + +import pytest + +from ansible_creator.config import Config +from ansible_creator.exceptions import CreatorError + + +if TYPE_CHECKING: + from pathlib import Path + + from ansible_creator.output import Output + +from ansible_creator.subcommands.add import Add +from tests.defaults import FIXTURES_DIR + + +class ConfigDict(TypedDict): + """Type hint for Config dictionary. + + Attributes: + creator_version: The version of the creator. + output: The output object to use for logging. + subcommand: The subcommand to execute. + resource_type: The type of resource to be scaffolded. + type: The type of the project for which the resource is being scaffolded. + path: The file path where the resource should be added. + force: Force overwrite of existing directory. + overwrite: To overwrite files in an existing directory. + no_overwrite: To not overwrite files in an existing directory. + """ + + creator_version: str + output: Output + subcommand: str + resource_type: str + type: str + path: str + force: bool + overwrite: bool + no_overwrite: bool + + +@pytest.fixture(name="cli_args") +def fixture_cli_args(tmp_path: Path, output: Output) -> ConfigDict: + """Create a dict to use for a Add class object as fixture. + + Args: + tmp_path: Temporary directory path. + output: Output class object. + + Returns: + dict: Dictionary, partial Add class object. + """ + return { + "creator_version": "0.0.1", + "output": output, + "subcommand": "add", + "type": "resource", + "resource_type": "devfile", + "path": str(tmp_path), + "force": False, + "overwrite": False, + "no_overwrite": False, + } + + +def has_differences(dcmp: dircmp[str], errors: list[str]) -> list[str]: + """Recursively check for differences in dircmp object. + + Args: + dcmp: dircmp object. + errors: List of errors. + + Returns: + list: List of errors. + """ + errors.extend([f"Only in {dcmp.left}: {f}" for f in dcmp.left_only]) + errors.extend([f"Only in {dcmp.right}: {f}" for f in dcmp.right_only]) + errors.extend( + [f"Differing files: {dcmp.left}/{f} {dcmp.right}/{f}" for f in dcmp.diff_files], + ) + for subdcmp in dcmp.subdirs.values(): + errors = has_differences(subdcmp, errors) + return errors + + +def test_run_success_add_devfile( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + cli_args: ConfigDict, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Add.run(). + + Successfully add devfile to path + + Args: + capsys: Pytest fixture to capture stdout and stderr. + tmp_path: Temporary directory path. + cli_args: Dictionary, partial Add class object. + monkeypatch: Pytest monkeypatch fixture. + """ + add = Add( + Config(**cli_args), + ) + + # Mock the "unique_name_in_devfile" method + def mock_unique_name_in_devfile() -> str: + """Mock function to generate a unique name for use in a devfile. + + Returns: + str: A placeholder name, "testorg". + """ + return "testorg" + + with pytest.MonkeyPatch.context() as mp: + # Apply the mock + mp.setattr( + Add, + "unique_name_in_devfile", + staticmethod(mock_unique_name_in_devfile), + ) + add.run() + result = capsys.readouterr().out + assert re.search("Note: Resource added to", result) is not None + + expected_devfile = tmp_path / "devfile.yaml" + effective_devfile = FIXTURES_DIR / "common" / "devfile" / "devfile.yaml" + cmp_result = cmp(expected_devfile, effective_devfile, shallow=False) + assert cmp_result + + conflict_file = tmp_path / "devfile.yaml" + conflict_file.write_text("schemaVersion: 2.2.2") + + # expect a CreatorError when the response to overwrite is no. + monkeypatch.setattr("builtins.input", lambda _: "n") + fail_msg = ( + "The destination directory contains files that will be overwritten." + " Please re-run ansible-creator with --overwrite to continue." + ) + with pytest.raises( + CreatorError, + match=fail_msg, + ): + add.run() + + # expect a warning followed by playbook project creation msg + # when response to overwrite is yes. + monkeypatch.setattr("builtins.input", lambda _: "y") + add.run() + result = capsys.readouterr().out + assert ( + re.search( + "already exists", + result, + ) + is not None + ), result + assert re.search("Note: Resource added to", result) is not None + + +def test_run_error_no_overwrite( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + cli_args: ConfigDict, +) -> None: + """Test Add.run(). + + Successfully add devfile to path + + Args: + capsys: Pytest fixture to capture stdout and stderr. + tmp_path: Temporary directory path. + cli_args: Dictionary, partial Add class object. + """ + add = Add( + Config(**cli_args), + ) + + # Mock the "unique_name_in_devfile" method + def mock_unique_name_in_devfile() -> str: + """Mock function to generate a unique name for use in a devfile. + + Returns: + str: A placeholder name, "testorg". + """ + return "testorg" + + with pytest.MonkeyPatch.context() as mp: + # Apply the mock + mp.setattr( + Add, + "unique_name_in_devfile", + staticmethod(mock_unique_name_in_devfile), + ) + add.run() + result = capsys.readouterr().out + assert re.search("Note: Resource added to", result) is not None + + expected_devfile = tmp_path / "devfile.yaml" + effective_devfile = FIXTURES_DIR / "common" / "devfile" / "devfile.yaml" + cmp_result = cmp(expected_devfile, effective_devfile, shallow=False) + assert cmp_result + + conflict_file = tmp_path / "devfile.yaml" + conflict_file.write_text("schemaVersion: 2.2.2") + + cli_args["no_overwrite"] = True + add = Add( + Config(**cli_args), + ) + with pytest.raises(CreatorError) as exc_info: + add.run() + assert "Please re-run ansible-creator with --overwrite to continue." in str(exc_info.value) + + +def test_error_invalid_path( + cli_args: ConfigDict, +) -> None: + """Test Add.run(). + + Successfully add devfile to path + + Args: + cli_args: Dictionary, partial Add class object. + """ + cli_args["path"] = "/invalid" + add = Add( + Config(**cli_args), + ) + + with pytest.raises(CreatorError) as exc_info: + add.run() + assert "does not exist. Please provide an existing directory" in str(exc_info.value) + + +def test_run_error_unsupported_resource_type( + cli_args: ConfigDict, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Add.run() with an unsupported resource type. + + This test checks if the CreatorError is raised when an unsupported + resource type is provided. + + Args: + cli_args: Dictionary, partial Add class object. + monkeypatch: Pytest monkeypatch fixture. + """ + add = Add( + Config(**cli_args), + ) + # Mock the _resource_type to bypass the validation step + monkeypatch.setattr(add, "_resource_type", "unsupported_type") + + # Expect a CreatorError with the appropriate message + with pytest.raises(CreatorError) as exc_info: + add.run() + assert "Unsupported resource type: unsupported_type" in str(exc_info.value)