From 7ca0789ef51fa3efa32894f8c46ac43d91fb5296 Mon Sep 17 00:00:00 2001 From: Shatakshi Mishra Date: Wed, 15 Jan 2025 20:43:07 +0530 Subject: [PATCH] Scaffold action plugin through add subcommand (#348) * Scaffold action plugin through add subcommand * Make seperate functions to collect and store resources based on plugin_type * Changes for adding module path * Fix logic for module scaffolding as part of adding action plugin * Chages in the module template to fix the error: DOCUMENTATION.module- not a valid value for dictionary value quit exit * fix the arg spec validation related errors in plugin template * add a function to update galaxy dependency for action plugin * logic cleanup * dependency key update and add checks for it * move the update_galaxy_dependency func back to add.py and initial tests for action plugin * test update_galaxy_dependency function * correct the debug message * author name change in the module documentation --- .config/dictionary.txt | 1 + src/ansible_creator/arg_parser.py | 6 +- .../plugins/action/hello_world.py.j2 | 91 +++++++++++++ .../plugins/modules/hello_world.py.j2 | 37 ++++++ src/ansible_creator/subcommands/add.py | 90 +++++++++++-- src/ansible_creator/utils.py | 41 ++++-- .../testcol/plugins/action/hello_world.py | 86 ++++++++++++ .../testcol/plugins/modules/hello_world.py | 35 +++++ tests/units/test_add.py | 125 +++++++++++++++++- 9 files changed, 486 insertions(+), 26 deletions(-) create mode 100644 src/ansible_creator/resources/collection_project/plugins/action/hello_world.py.j2 create mode 100644 src/ansible_creator/resources/collection_project/plugins/modules/hello_world.py.j2 create mode 100644 tests/fixtures/collection/testorg/testcol/plugins/action/hello_world.py create mode 100644 tests/fixtures/collection/testorg/testcol/plugins/modules/hello_world.py diff --git a/.config/dictionary.txt b/.config/dictionary.txt index 1a2affc..d32640e 100644 --- a/.config/dictionary.txt +++ b/.config/dictionary.txt @@ -2,6 +2,7 @@ addopts antsibull argcomplete argnames +argspec argvalues capsys chakarborty diff --git a/src/ansible_creator/arg_parser.py b/src/ansible_creator/arg_parser.py index e3c4afa..2781712 100644 --- a/src/ansible_creator/arg_parser.py +++ b/src/ansible_creator/arg_parser.py @@ -34,10 +34,7 @@ MIN_COLLECTION_NAME_LEN = 2 -COMING_SOON = ( - "add resource role", - "add plugin action", -) +COMING_SOON = ("add resource role",) class Parser: @@ -369,6 +366,7 @@ def _add_plugin_action(self, subparser: SubParser[ArgumentParser]) -> None: formatter_class=CustomHelpFormatter, ) self._add_args_common(parser) + self._add_overwrite(parser) self._add_args_plugin_common(parser) def _add_plugin_filter(self, subparser: SubParser[ArgumentParser]) -> None: diff --git a/src/ansible_creator/resources/collection_project/plugins/action/hello_world.py.j2 b/src/ansible_creator/resources/collection_project/plugins/action/hello_world.py.j2 new file mode 100644 index 0000000..a20edf7 --- /dev/null +++ b/src/ansible_creator/resources/collection_project/plugins/action/hello_world.py.j2 @@ -0,0 +1,91 @@ +{# action_plugin_template.j2 #} +{%- set action_name = plugin_name | default("hello_world") -%} +{%- set author = author | default("Your Name") -%} +{%- set description = description | default("A custom action plugin for Ansible.") -%} +{%- set license = license | default("GPL-3.0-or-later") -%} +# {{ action_name }}.py - {{ description }} +# Author: {{ author }} +# License: {{ license }} +# pylint: disable=E0401 + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=C0103 + +from typing import TYPE_CHECKING +from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( # type: ignore + AnsibleArgSpecValidator, +) +from ansible_collections.ansible.utils.plugins.modules.fact_diff import DOCUMENTATION # type: ignore +from ansible.plugins.action import ActionBase # type: ignore + + +if TYPE_CHECKING: + from typing import Optional, Dict, Any + + +class ActionModule(ActionBase): # type: ignore[misc] + """ + Custom Ansible action plugin: {{ action_name }} + A custom action plugin for Ansible. + """ + + def _check_argspec(self, result: dict[str, Any]) -> None: + aav = AnsibleArgSpecValidator( + data=self._task.args, + schema=DOCUMENTATION, + schema_format="doc", + name=self._task.action, + ) + valid, errors, self._task.args = aav.validate() + if not valid: + result["failed"] = True + result["msg"] = errors + + def run( + self, + tmp: Optional[str] = None, + task_vars: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Executes the action plugin. + + Args: + tmp: Temporary path provided by Ansible for the module execution. Defaults to None. + task_vars: Dictionary of task variables available to the plugin. Defaults to None. + + Returns: + dict: Result of the action plugin execution. + """ + # Get the task arguments + if task_vars is None: + task_vars = {} + result = {} + warnings: list[str] = [] + + # Example processing logic - Replace this with actual action code + result = super(ActionModule, self).run(tmp, task_vars) + self._check_argspec(result) + + # Copy the task arguments + module_args = self._task.args.copy() + + prefix = module_args.get("prefix", "DefaultPrefix") + message = module_args.get("msg", "No message provided") + module_args["msg"] = f"{prefix}: {message}" + + result.update( + self._execute_module( + module_name="debug", + module_args=module_args, + task_vars=task_vars, + tmp=tmp, + ), + ) + + if warnings: + if "warnings" in result: + result["warnings"].extend(warnings) + else: + result["warnings"] = warnings + return result diff --git a/src/ansible_creator/resources/collection_project/plugins/modules/hello_world.py.j2 b/src/ansible_creator/resources/collection_project/plugins/modules/hello_world.py.j2 new file mode 100644 index 0000000..01ba211 --- /dev/null +++ b/src/ansible_creator/resources/collection_project/plugins/modules/hello_world.py.j2 @@ -0,0 +1,37 @@ +{%- set module_name = plugin_name | default("hello_world") -%} +{%- set author = author | default("Your Name (@username)") -%} +# {{ module_name }}.py +# GNU General Public License v3.0+ + +DOCUMENTATION = """ + module: {{ module_name }} + author: {{ author }} + version_added: "1.0.0" + short_description: A custom action plugin for Ansible. + description: + - This is a custom action plugin to provide action functionality. + options: + prefix: + description: + - A string that is added as a prefix to the message passed to the module. + type: str + msg: + description: The message to display in the output. + type: str + with_prefix: + description: + - A boolean flag indicating whether to include the prefix in the message. + type: bool + notes: + - This is a scaffold template. Customize the plugin to fit your needs. +""" + +EXAMPLES = """ +- name: Example Action Plugin + hosts: localhost + tasks: + - name: Example {{ module_name }} plugin + with_prefix: + prefix: "Hello, World" + msg: "Ansible!" +""" diff --git a/src/ansible_creator/subcommands/add.py b/src/ansible_creator/subcommands/add.py index 452d7d9..7fdf505 100644 --- a/src/ansible_creator/subcommands/add.py +++ b/src/ansible_creator/subcommands/add.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import TYPE_CHECKING +import yaml + from ansible_creator.constants import GLOBAL_TEMPLATE_VARS from ansible_creator.exceptions import CreatorError from ansible_creator.templar import Templar @@ -92,13 +94,33 @@ def unique_name_in_devfile(self) -> str: final_uuid = str(uuid.uuid4())[:8] return f"{final_name}-{final_uuid}" + def update_galaxy_dependency(self) -> None: + """Update galaxy.yml file with the required dependency.""" + galaxy_file = self._add_path / "galaxy.yml" + + # Load the galaxy.yml file + with galaxy_file.open("r", encoding="utf-8") as file: + data = yaml.safe_load(file) + + # Ensure the dependencies key exists + if "dependencies" not in data: + data["dependencies"] = {"ansible.utils": "*"} + + # Empty dependencies key or dependencies key without ansible.utils + elif not data["dependencies"] or "ansible.utils" not in data["dependencies"]: + data["dependencies"]["ansible.utils"] = "*" + + # Save the updated YAML back to the file + with galaxy_file.open("w", encoding="utf-8") as file: + yaml.dump(data, file, sort_keys=False) + def _resource_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") + self.output.debug(f"Started adding {self._resource_type} to destination") # Call the appropriate scaffolding function based on the resource type if self._resource_type == "devfile": @@ -171,22 +193,66 @@ def _plugin_scaffold(self, plugin_path: Path) -> None: Raises: CreatorError: If unsupported plugin type is given. """ - self.output.debug(f"Started copying {self._project} plugin to destination") + self.output.debug(f"Started adding {self._plugin_type} plugin to destination") # Call the appropriate scaffolding function based on the plugin type - if self._plugin_type in ("lookup", "filter"): + if self._plugin_type == "action": + self.update_galaxy_dependency() template_data = self._get_plugin_template_data() + self._perform_action_plugin_scaffold(template_data, plugin_path) + + elif self._plugin_type == "filter": + template_data = self._get_plugin_template_data() + self._perform_filter_plugin_scaffold(template_data, plugin_path) + + elif self._plugin_type == "lookup": + template_data = self._get_plugin_template_data() + self._perform_lookup_plugin_scaffold(template_data, plugin_path) else: msg = f"Unsupported plugin type: {self._plugin_type}" raise CreatorError(msg) - self._perform_plugin_scaffold(template_data, plugin_path) + def _perform_action_plugin_scaffold( + self, + template_data: TemplateData, + plugin_path: Path, + ) -> None: + resources = ( + f"collection_project.plugins.{self._plugin_type}", + "collection_project.plugins.modules", + ) + module_path = self._add_path / "plugins" / "modules" + module_path.mkdir(parents=True, exist_ok=True) + final_plugin_path = [plugin_path, module_path] + self._perform_plugin_scaffold(resources, template_data, final_plugin_path) + + def _perform_filter_plugin_scaffold( + self, + template_data: TemplateData, + plugin_path: Path, + ) -> None: + resources = (f"collection_project.plugins.{self._plugin_type}",) + self._perform_plugin_scaffold(resources, template_data, plugin_path) - def _perform_plugin_scaffold(self, template_data: TemplateData, plugin_path: Path) -> None: + def _perform_lookup_plugin_scaffold( + self, + template_data: TemplateData, + plugin_path: Path, + ) -> None: + resources = (f"collection_project.plugins.{self._plugin_type}",) + self._perform_plugin_scaffold(resources, template_data, plugin_path) + + def _perform_plugin_scaffold( + self, + resources: tuple[str, ...], + template_data: TemplateData, + plugin_path: Path | list[Path], + ) -> None: """Perform the actual scaffolding process using the provided template data. Args: + resources: Tuple of resources. template_data: TemplateData plugin_path: Path where the plugin will be scaffolded. @@ -195,7 +261,7 @@ def _perform_plugin_scaffold(self, template_data: TemplateData, plugin_path: Pat destination directory contains files that will be overwritten. """ walker = Walker( - resources=(f"collection_project.plugins.{self._plugin_type}",), + resources=resources, resource_id=self._plugin_id, dest=plugin_path, output=self.output, @@ -213,6 +279,10 @@ def _perform_plugin_scaffold(self, template_data: TemplateData, plugin_path: Pat ) raise CreatorError(msg) + # This check is for action plugins (having module file as an additional path) + if isinstance(plugin_path, list): + plugin_path = plugin_path[0] + if not paths.has_conflicts() or self._force or self._overwrite: copier.copy_containers(paths) self.output.note(f"{self._plugin_type.capitalize()} plugin added to {plugin_path}") @@ -270,10 +340,10 @@ def _get_devcontainer_template_data(self) -> TemplateData: ) def _get_plugin_template_data(self) -> TemplateData: - """Get the template data for lookup plugin. + """Get the template data for plugin. Returns: - TemplateData: Data required for templating the lookup plugin. + TemplateData: Data required for templating the plugin. """ return TemplateData( plugin_type=self._plugin_type, @@ -282,10 +352,10 @@ def _get_plugin_template_data(self) -> TemplateData: ) def _get_ee_template_data(self) -> TemplateData: - """Get the template data for lookup plugin. + """Get the template data for plugin. Returns: - TemplateData: Data required for templating the lookup plugin. + TemplateData: Data required for templating the plugin. """ return TemplateData( resource_type=self._resource_type, diff --git a/src/ansible_creator/utils.py b/src/ansible_creator/utils.py index 7637f75..e60bf44 100644 --- a/src/ansible_creator/utils.py +++ b/src/ansible_creator/utils.py @@ -183,7 +183,7 @@ class Walker: resources: tuple[str, ...] resource_id: str - dest: Path + dest: Path | list[Path] output: Output template_data: TemplateData resource_root: str = "ansible_creator.resources" @@ -193,6 +193,7 @@ def _recursive_walk( self, root: Traversable, resource: str, + current_index: int, template_data: TemplateData, ) -> FileList: """Recursively traverses a resource container looking for content to copy. @@ -200,6 +201,7 @@ def _recursive_walk( Args: root: A traversable object representing root of the container to copy. resource: The resource being scanned. + current_index: Current index in the list of objects. template_data: A dictionary containing current data to render templates with. Returns: @@ -211,6 +213,7 @@ def _recursive_walk( for obj in root.iterdir(): file_list.extend( self.each_obj( + current_index, obj, resource=resource, template_data=template_data, @@ -220,6 +223,7 @@ def _recursive_walk( def each_obj( self, + current_index: int, obj: Traversable, resource: str, template_data: TemplateData, @@ -227,6 +231,7 @@ def each_obj( """Recursively traverses a resource container and copies content to destination. Args: + current_index: Current index in the list of objects. obj: A traversable object representing the root of the container to copy. resource: The resource to consult for path names. template_data: A dictionary containing current data to render templates with. @@ -246,10 +251,19 @@ def each_obj( dest_name = dest_name.replace(key, repl_val) dest_name = dest_name.removesuffix(".j2") - dest_path = DestinationFile( - dest=self.dest / dest_name, - source=obj, - ) + if isinstance(self.dest, list): + # If self.dest is a list of Path + dest_path = DestinationFile( + dest=self.dest[current_index] / dest_name, + source=obj, + ) + else: + # If self.dest is a single Path + dest_path = DestinationFile( + dest=self.dest / dest_name, + source=obj, + ) + self.output.debug(f"Looking at {dest_path}") if obj.is_file(): @@ -268,6 +282,7 @@ def each_obj( *self._recursive_walk( root=obj, resource=resource, + current_index=current_index, template_data=template_data, ), ], @@ -276,15 +291,21 @@ def each_obj( return FileList([dest_path]) if obj.is_dir() and obj.name not in SKIP_DIRS: - return self._recursive_walk(root=obj, resource=resource, template_data=template_data) + return self._recursive_walk( + root=obj, + resource=resource, + current_index=current_index, + template_data=template_data, + ) return FileList() - def _per_container(self, resource: str) -> FileList: + def _per_container(self, resource: str, current_index: int) -> FileList: """Generate a list of all paths that will be written to for a particular resource. Args: resource: The resource to search through. + current_index: Current index in the list of objects. Returns: A list of paths to be written to. @@ -323,6 +344,7 @@ def _per_container(self, resource: str) -> FileList: return self._recursive_walk( impl_resources.files(f"{self.resource_root}.{resource}"), resource, + current_index, template_data, ) @@ -333,8 +355,9 @@ def collect_paths(self) -> FileList: A list of paths to be written to. """ file_list = FileList() - for resource in self.resources: - file_list.extend(self._per_container(resource)) + current_index: int = 0 + for current_index, resource in enumerate(self.resources): + file_list.extend(self._per_container(resource, current_index)) return file_list diff --git a/tests/fixtures/collection/testorg/testcol/plugins/action/hello_world.py b/tests/fixtures/collection/testorg/testcol/plugins/action/hello_world.py new file mode 100644 index 0000000..4ba3bc0 --- /dev/null +++ b/tests/fixtures/collection/testorg/testcol/plugins/action/hello_world.py @@ -0,0 +1,86 @@ +# hello_world.py - A custom action plugin for Ansible. +# Author: Your Name +# License: GPL-3.0-or-later +# pylint: disable=E0401 + +from __future__ import absolute_import, annotations, division, print_function + +__metaclass__ = type # pylint: disable=C0103 + +from typing import TYPE_CHECKING +from ansible_collections.ansible.utils.plugins.module_utils.common.argspec_validate import ( # type: ignore + AnsibleArgSpecValidator, +) +from ansible_collections.ansible.utils.plugins.modules.fact_diff import DOCUMENTATION # type: ignore +from ansible.plugins.action import ActionBase # type: ignore + + +if TYPE_CHECKING: + from typing import Optional, Dict, Any + + +class ActionModule(ActionBase): # type: ignore[misc] + """ + Custom Ansible action plugin: hello_world + A custom action plugin for Ansible. + """ + + def _check_argspec(self, result: dict[str, Any]) -> None: + aav = AnsibleArgSpecValidator( + data=self._task.args, + schema=DOCUMENTATION, + schema_format="doc", + name=self._task.action, + ) + valid, errors, self._task.args = aav.validate() + if not valid: + result["failed"] = True + result["msg"] = errors + + def run( + self, + tmp: Optional[str] = None, + task_vars: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + Executes the action plugin. + + Args: + tmp: Temporary path provided by Ansible for the module execution. Defaults to None. + task_vars: Dictionary of task variables available to the plugin. Defaults to None. + + Returns: + dict: Result of the action plugin execution. + """ + # Get the task arguments + if task_vars is None: + task_vars = {} + result = {} + warnings: list[str] = [] + + # Example processing logic - Replace this with actual action code + result = super(ActionModule, self).run(tmp, task_vars) + self._check_argspec(result) + + # Copy the task arguments + module_args = self._task.args.copy() + + prefix = module_args.get("prefix", "DefaultPrefix") + message = module_args.get("msg", "No message provided") + module_args["msg"] = f"{prefix}: {message}" + + result.update( + self._execute_module( + module_name="debug", + module_args=module_args, + task_vars=task_vars, + tmp=tmp, + ), + ) + + if warnings: + if "warnings" in result: + result["warnings"].extend(warnings) + else: + result["warnings"] = warnings + return result diff --git a/tests/fixtures/collection/testorg/testcol/plugins/modules/hello_world.py b/tests/fixtures/collection/testorg/testcol/plugins/modules/hello_world.py new file mode 100644 index 0000000..f213833 --- /dev/null +++ b/tests/fixtures/collection/testorg/testcol/plugins/modules/hello_world.py @@ -0,0 +1,35 @@ +# hello_world.py +# GNU General Public License v3.0+ + +DOCUMENTATION = """ + module: hello_world + author: Your Name (@username) + version_added: "1.0.0" + short_description: A custom action plugin for Ansible. + description: + - This is a custom action plugin to provide action functionality. + options: + prefix: + description: + - A string that is added as a prefix to the message passed to the module. + type: str + msg: + description: The message to display in the output. + type: str + with_prefix: + description: + - A boolean flag indicating whether to include the prefix in the message. + type: bool + notes: + - This is a scaffold template. Customize the plugin to fit your needs. +""" + +EXAMPLES = """ +- name: Example Action Plugin + hosts: localhost + tasks: + - name: Example hello_world plugin + with_prefix: + prefix: "Hello, World" + msg: "Ansible!" +""" diff --git a/tests/units/test_add.py b/tests/units/test_add.py index 59d3603..eb24411 100644 --- a/tests/units/test_add.py +++ b/tests/units/test_add.py @@ -10,9 +10,10 @@ import sys from filecmp import cmp, dircmp -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING, Any, TypedDict import pytest +import yaml from ansible_creator.config import Config from ansible_creator.exceptions import CreatorError @@ -513,7 +514,7 @@ def mock_check_collection_path() -> None: ): add.run() - # expect a warning followed by playbook project creation msg + # expect a warning followed by filter plugin addition msg # when response to overwrite is yes. monkeypatch.setattr("builtins.input", lambda _: "y") add.run() @@ -590,7 +591,7 @@ def mock_check_collection_path() -> None: ): add.run() - # expect a warning followed by playbook project creation msg + # expect a warning followed by lookup plugin addition msg # when response to overwrite is yes. monkeypatch.setattr("builtins.input", lambda _: "y") add.run() @@ -605,6 +606,75 @@ def mock_check_collection_path() -> None: assert re.search("Note: Lookup plugin added to", result) is not None +def test_run_success_add_plugin_action( + capsys: pytest.CaptureFixture[str], + tmp_path: Path, + cli_args: ConfigDict, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Add.run(). + + Successfully add plugin 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. + """ + cli_args["plugin_type"] = "action" + add = Add( + Config(**cli_args), + ) + + # Mock the "_check_collection_path" method + def mock_check_collection_path() -> None: + """Mock function to skip checking collection path.""" + + monkeypatch.setattr( + Add, + "_check_collection_path", + staticmethod(mock_check_collection_path), + ) + + # Mock the "update_galaxy_dependency" method + def mock_update_galaxy_dependency() -> None: + """Mock function to skip updating galaxy file.""" + + monkeypatch.setattr( + Add, + "update_galaxy_dependency", + staticmethod(mock_update_galaxy_dependency), + ) + + add.run() + result = capsys.readouterr().out + assert re.search("Note: Action plugin added to", result) is not None + + expected_plugin_file = tmp_path / "plugins" / "action" / "hello_world.py" + expected_module_file = tmp_path / "plugins" / "modules" / "hello_world.py" + effective_plugin_file = ( + FIXTURES_DIR + / "collection" + / "testorg" + / "testcol" + / "plugins" + / "action" + / "hello_world.py" + ) + effective_module_file = ( + FIXTURES_DIR + / "collection" + / "testorg" + / "testcol" + / "plugins" + / "modules" + / "hello_world.py" + ) + cmp_result1 = cmp(expected_plugin_file, effective_plugin_file, shallow=False) + cmp_result2 = cmp(expected_module_file, effective_module_file, shallow=False) + assert cmp_result1, cmp_result2 + + def test_run_error_plugin_no_overwrite( capsys: pytest.CaptureFixture[str], tmp_path: Path, @@ -761,3 +831,52 @@ def test_run_success_add_execution_env( is not None ), result assert re.search("Note: Resource added to", result) is not None + + +def test_update_galaxy_dependency(tmp_path: Path, cli_args: ConfigDict) -> None: + """Test update_galaxy_dependency method. + + Args: + tmp_path: Temporary directory path. + cli_args: Dictionary, partial Add class object. + """ + galaxy_file = tmp_path / "galaxy.yml" + initial_data: dict[str, Any] + + # Test case 1: No dependencies key + initial_data = {"name": "test_collection"} + galaxy_file.write_text(yaml.dump(initial_data)) + add = Add(Config(**cli_args)) + add.update_galaxy_dependency() + + with galaxy_file.open("r") as file: + updated_data = yaml.safe_load(file) + assert "dependencies" in updated_data + assert updated_data["dependencies"] == {"ansible.utils": "*"} + + # Test case 2: Empty dependencies + initial_data = {"name": "test_collection", "dependencies": {}} + galaxy_file.write_text(yaml.dump(initial_data)) + add.update_galaxy_dependency() + + with galaxy_file.open("r") as file: + updated_data = yaml.safe_load(file) + assert updated_data["dependencies"] == {"ansible.utils": "*"} + + # Test case 3: Existing dependencies without ansible.utils + initial_data = {"name": "test_collection", "dependencies": {"another.dep": "1.0.0"}} + galaxy_file.write_text(yaml.dump(initial_data)) + add.update_galaxy_dependency() + + with galaxy_file.open("r") as file: + updated_data = yaml.safe_load(file) + assert updated_data["dependencies"] == {"another.dep": "1.0.0", "ansible.utils": "*"} + + # Test case 4: Existing dependencies with ansible.utils + initial_data = {"name": "test_collection", "dependencies": {"ansible.utils": "1.0.0"}} + galaxy_file.write_text(yaml.dump(initial_data)) + add.update_galaxy_dependency() + + with galaxy_file.open("r") as file: + updated_data = yaml.safe_load(file) + assert updated_data["dependencies"] == {"ansible.utils": "1.0.0"}