From c88462ec2ee1a73ac210065398e5a33f8096f201 Mon Sep 17 00:00:00 2001 From: s-hertel <19572925+s-hertel@users.noreply.github.com> Date: Thu, 20 Feb 2025 17:42:37 -0500 Subject: [PATCH 1/4] Add ansible-galaxy uninstall to remove collections and roles Roles and collections can be uninstalled at the same time by providing a requirements file Only direct requests are removed. Dependency removal is not supported. Unlike legacy role removal: - user confirmation is supported - if a role version is specified, only a matching version will be removed - roles without metadata can be removed - all matching roles can be removed, instead of only the first found --- .../add-ansible-galaxy-uninstall.yml | 6 ++ lib/ansible/cli/galaxy.py | 80 +++++++++++++++++- lib/ansible/galaxy/__init__.py | 40 +++++++++ lib/ansible/galaxy/collection/__init__.py | 43 ++++++++++ lib/ansible/galaxy/role.py | 32 +++++++ .../ansible-galaxy-collection/tasks/main.yml | 8 ++ .../tasks/uninstall.yml | 84 +++++++++++++++++++ 7 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/add-ansible-galaxy-uninstall.yml create mode 100644 test/integration/targets/ansible-galaxy-collection/tasks/uninstall.yml diff --git a/changelogs/fragments/add-ansible-galaxy-uninstall.yml b/changelogs/fragments/add-ansible-galaxy-uninstall.yml new file mode 100644 index 00000000000..111a70eef43 --- /dev/null +++ b/changelogs/fragments/add-ansible-galaxy-uninstall.yml @@ -0,0 +1,6 @@ +minor_changes: +- >- + Added simple ``uninstall`` functionality to ``ansible-galaxy``. + Unless ``--yes``/``-y`` is provided, paths will only be removed after user confirmation. + Provide a ``--requirements-file`` to uninstall roles and collections at the same time. + Dependencies are not uninstalled, only direct requsts. diff --git a/lib/ansible/cli/galaxy.py b/lib/ansible/cli/galaxy.py index 76e566f4a5c..31a6fe37ff6 100755 --- a/lib/ansible/cli/galaxy.py +++ b/lib/ansible/cli/galaxy.py @@ -36,6 +36,7 @@ from ansible.galaxy.collection import ( find_existing_collections, install_collections, publish_collection, + uninstall_collections, validate_collection_name, validate_collection_path, verify_collections, @@ -47,7 +48,7 @@ from ansible.galaxy.collection.concrete_artifact_manager import ( from ansible.galaxy.collection.gpg import GPG_ERROR_MAP from ansible.galaxy.dependency_resolution.dataclasses import Requirement -from ansible.galaxy.role import GalaxyRole +from ansible.galaxy.role import GalaxyRole, uninstall_roles from ansible.galaxy.token import BasicAuthToken, GalaxyToken, KeycloakToken, NoTokenSentinel from ansible.module_utils.ansible_release import __version__ as ansible_version from ansible.module_utils.common.collections import is_iterable @@ -282,6 +283,7 @@ class GalaxyCLI(CLI): self.add_publish_options(collection_parser, parents=[common]) self.add_install_options(collection_parser, parents=[common, force, cache_options]) self.add_list_options(collection_parser, parents=[common, collections_path]) + self.add_uninstall_options(collection_parser, parents=[common]) self.add_verify_options(collection_parser, parents=[common, collections_path]) # Add sub parser for the Galaxy role actions @@ -299,6 +301,7 @@ class GalaxyCLI(CLI): self.add_info_options(role_parser, parents=[common, roles_path, offline]) self.add_install_options(role_parser, parents=[common, force, roles_path]) + self.add_uninstall_options(role_parser, parents=[common]) def add_download_options(self, parser, parents=None): download_parser = parser.add_parser('download', parents=parents, @@ -375,6 +378,32 @@ class GalaxyCLI(CLI): list_parser.add_argument('--format', dest='output_format', choices=('human', 'yaml', 'json'), default='human', help="Format to display the list of collections in.") + def add_uninstall_options(self, parser, parents=None): + galaxy_type = "role" if parser.metavar == "ROLE_ACTION" else "collection" + + uninstall_parser = parser.add_parser("uninstall", parents=parents or [], + help=f"Uninstall collections from {C.COLLECTIONS_PATHS} and roles from {C.DEFAULT_ROLES_PATH}") + + if galaxy_type == "role": + uninstall_parser.add_argument("--roles-path", dest="roles_path", default=C.DEFAULT_ROLES_PATH, + type=opt_help.unfrack_path(pathsep=True), action=opt_help.PrependListAction, + help="Remove roles from additional paths.") + if self._implicit_role or galaxy_type == "collection": + uninstall_parser.add_argument("--collections-path", dest="collections_path", default=C.COLLECTIONS_PATHS, + type=opt_help.unfrack_path(pathsep=True), action=opt_help.PrependListAction, + help="Remove collections from additional paths.") + + # one required, mutually exclusive + uninstall_parser.add_argument("args", metavar=f"{galaxy_type}_name", nargs="*", help=f"{galaxy_type.upper()} to uninstall") + uninstall_parser.add_argument("-r", "--requirements-file", dest="requirements", + help="A file containing a list of collections to be installed.") + + # bypass prompting + uninstall_parser.add_argument("-y", "--yes", dest="yes", action="store_true", default=False, + help=f"Remove all {galaxy_type}s matching the requirements without prompting") + + uninstall_parser.set_defaults(func=self.execute_uninstall) + def add_search_options(self, parser, parents=None): search_parser = parser.add_parser('search', parents=parents, help='Search the Galaxy database by tags, platforms, author and multiple ' @@ -1623,6 +1652,55 @@ class GalaxyCLI(CLI): return 0 + @with_collection_artifacts_manager + def execute_uninstall(self, artifacts_manager=None): + """Locate collections/roles that match any requirement, and get user confirmation on which paths to remove.""" + if artifacts_manager is not None: + artifacts_manager.require_build_metadata = False + + if context.CLIARGS["args"] and context.CLIARGS["requirements"]: + raise AnsibleOptionsError(f"Positional {context.CLIARGS['type']} requirements and --requirements-file are mutually exclusive.") + if not context.CLIARGS["args"] and not context.CLIARGS["requirements"]: + raise AnsibleOptionsError(f"Positional {context.CLIARGS['type']} requirements or --requirements-file is required.") + + collection_requirements = [] + role_requirements = [] + + if context.CLIARGS["type"] == "role" and context.CLIARGS["args"]: + for role_requirement in context.CLIARGS["args"]: + role_spec = RoleRequirement.role_yaml_parse(role_requirement) + if "role" in role_spec: + name = role_spec["role"] + elif "name" in role_spec: + name = role_spec["name"] + elif "src" in role_spec: + name = RoleRequirement.repo_url_to_role_name(role_spec["src"]) + else: + raise AnsibleError("Invalid role requirement: '{role_requirement}'. A role name is required.") + role_requirements.append(GalaxyRole(self.galaxy, self.lazy_role_api, name, version=role_spec.get("version"))) + elif context.CLIARGS["args"]: + for collection_requirement in context.CLIARGS["args"]: + collection_requirements.append( + Requirement.from_string(collection_requirement, artifacts_manager, None) + ) + else: + requirements_file = GalaxyCLI._resolve_path(context.CLIARGS["requirements"]) + requirements = self._parse_requirements_file( + requirements_file, allow_old_format=False, artifacts_manager=artifacts_manager, validate_signature_options=False + ) + collection_requirements = requirements["collections"] + role_requirements = requirements["roles"] + + rc = 0 + if self._implicit_role or context.CLIARGS["type"] == "collection": + rc = uninstall_collections( + collection_requirements, context.CLIARGS["collections_path"], context.CLIARGS["yes"], artifacts_manager=artifacts_manager + ) + if self._implicit_role or context.CLIARGS["type"] == "role": + rc = uninstall_roles(role_requirements, context.CLIARGS["roles_path"], context.CLIARGS["yes"]) or rc + + return rc + @with_collection_artifacts_manager def execute_list_collection(self, artifacts_manager=None): """ diff --git a/lib/ansible/galaxy/__init__.py b/lib/ansible/galaxy/__init__.py index 7b6fa569f4b..98e4cb25d5d 100644 --- a/lib/ansible/galaxy/__init__.py +++ b/lib/ansible/galaxy/__init__.py @@ -22,16 +22,56 @@ from __future__ import annotations +import shutil import os import ansible.constants as C from ansible import context +from ansible.utils.display import Display from ansible.module_utils.common.text.converters import to_bytes from ansible.module_utils.common.yaml import yaml_load # default_readme_template # default_meta_template +display = Display() + + +def _ask_uninstall(label: str, paths: list[str], collection: bool = True, skip_prompt: bool = False) -> bool: + """Confirm whether the list of paths should be removed and return a boolean.""" + if skip_prompt: + return True + + prompt = f"Uninstalling {'collection' if collection else 'role'} {label}:" + prompt += ( + "\n Would remove:\n " + + "\n ".join(paths) + ) + display.display(prompt) + while (response := input("Proceed (y/n)? ").lower()) not in ("y", "n"): + display.display(f"Your response ('{response}') was not one of the expected responses: y, n,") + return response == "y" + + +def _uninstall_paths(label: str, paths: list[str], collection: bool = True) -> int: + """Remove a list of paths, depth first.""" + rc = 0 + for path in reversed(sorted(paths)): + try: + if os.path.islink(path): + os.unlink(path) + else: + shutil.rmtree(path) + except OSError as e: + display.error(f"Unable to remove {'collection' if collection else 'role'} {label} path {path}: {e}") + rc = 1 + else: + display.vvvv(f"Removed {path}") + if rc == 0: + display.display(f"Uninstalled {'collection' if collection else 'role'} {label}") + + return rc + def get_collections_galaxy_meta_info(): meta_path = os.path.join(os.path.dirname(__file__), 'data', 'collections_galaxy_meta.yml') diff --git a/lib/ansible/galaxy/collection/__init__.py b/lib/ansible/galaxy/collection/__init__.py index 829f7aa19d2..70398e561c1 100644 --- a/lib/ansible/galaxy/collection/__init__.py +++ b/lib/ansible/galaxy/collection/__init__.py @@ -87,6 +87,7 @@ if t.TYPE_CHECKING: import ansible.constants as C from ansible.compat.importlib_resources import files from ansible.errors import AnsibleError +from ansible.galaxy import _ask_uninstall, _uninstall_paths from ansible.galaxy.api import GalaxyAPI from ansible.galaxy.collection.concrete_artifact_manager import ( _consume_file, @@ -956,6 +957,48 @@ def verify_collections( return results +def uninstall_collections(requirements: list[Requirement], search_paths: list[str], skip_prompt: bool, artifacts_manager: ConcreteArtifactsManager = None): + """Uninstall collections in any of the search paths that match a requirement.""" + collections = [] + for requirement in requirements: + namespace, name = requirement.fqcn.split(".") + for installed in find_existing_collections( + search_paths, + artifacts_manager, + namespace_filter=namespace, + collection_filter=name, + dedupe=False + ): + if requirement.ver in (None, "*") or meets_requirements(installed.ver, requirement.ver): + collections.append(installed) + + remove = set() + keep = set() + collection_paths = {} + for collection in collections: + collection_path = to_text(collection.src, errors="surrogate_or_strict") + if collection_path in remove or collection_path in keep: + continue + + content = [collection_path] + namespace = os.path.dirname(collection_path) + for other_collection in os.listdir(namespace): + if other_collection != collection.name and os.path.join(namespace, other_collection) not in remove: + break + else: + content.append(namespace) + content.extend(glob.glob(f"{os.path.dirname(namespace)}/{collection.fqcn}-*.info")) + if _ask_uninstall(f"{collection}", content, skip_prompt=skip_prompt): + remove.add(collection_path) + collection_paths[f"{collection}"] = content + else: + keep.add(collection_path) + + rc = 0 + for label, paths in collection_paths.items(): + rc = _uninstall_paths(label, paths) or rc + + @contextmanager def _tempdir(): b_temp_path = tempfile.mkdtemp(dir=to_bytes(C.DEFAULT_LOCAL_TMP, errors='surrogate_or_strict')) diff --git a/lib/ansible/galaxy/role.py b/lib/ansible/galaxy/role.py index 9ee7f3b9054..ec0228680e0 100644 --- a/lib/ansible/galaxy/role.py +++ b/lib/ansible/galaxy/role.py @@ -33,6 +33,7 @@ from shutil import rmtree from ansible import context from ansible.errors import AnsibleError, AnsibleParserError +from ansible.galaxy import _ask_uninstall, _uninstall_paths from ansible.galaxy.api import GalaxyAPI from ansible.galaxy.user_agent import user_agent from ansible.module_utils.common.text.converters import to_native, to_text @@ -479,3 +480,34 @@ class GalaxyRole(object): raise AnsibleParserError(f"Expected role dependencies to be a list. Role {self} has meta/requirements.yml {self._requirements}") return self._requirements + + +def uninstall_roles(requirements: list[GalaxyRole], search_paths: list[str], skip_prompt: bool) -> int: + """Uninstall all roles that match each requirement name (and optional version).""" + roles = [] + for requirement in requirements: + for path in search_paths: + for role_name in os.listdir(path): + if requirement.name != role_name: + continue + gr = GalaxyRole(requirement.galaxy, requirement.api, role_name, requirement.src, requirement.version, requirement.scm, path) + if requirement.version is None or (gr.install_info or {}).get("version") == requirement.version: + roles.append(gr) + + remove = set() + keep = set() + role_paths = {} + for role in roles: + if role.path in remove or role.path in keep: + continue + label = f"{role.name} (unknown version)" if not role.version else f"{role.name} ({role.version})" + if _ask_uninstall(label, [role.path], collection=False, skip_prompt=skip_prompt): + remove.add(role.path) + role_paths[label] = [role.path] + else: + keep.add(role.path) + + rc = 0 + for label, paths in role_paths.items(): + rc = _uninstall_paths(label, paths, collection=False) or rc + return rc diff --git a/test/integration/targets/ansible-galaxy-collection/tasks/main.yml b/test/integration/targets/ansible-galaxy-collection/tasks/main.yml index e17d6aa1224..9f2d97f7160 100644 --- a/test/integration/targets/ansible-galaxy-collection/tasks/main.yml +++ b/test/integration/targets/ansible-galaxy-collection/tasks/main.yml @@ -227,3 +227,11 @@ environment: ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}' ANSIBLE_CONFIG: '{{ galaxy_dir }}/ansible.cfg' + +- include_tasks: uninstall.yml + args: + apply: + environment: + ANSIBLE_COLLECTIONS_PATH: '{{ galaxy_dir }}' + ANSIBLE_CONFIG: '{{ galaxy_dir }}/ansible.cfg' + ANSIBLE_ROLES_PATH: '{{ galaxy_dir }}/roles' diff --git a/test/integration/targets/ansible-galaxy-collection/tasks/uninstall.yml b/test/integration/targets/ansible-galaxy-collection/tasks/uninstall.yml new file mode 100644 index 00000000000..c2a0329ef5a --- /dev/null +++ b/test/integration/targets/ansible-galaxy-collection/tasks/uninstall.yml @@ -0,0 +1,84 @@ +- name: Uninstall a collection which isn't installed + command: ansible-galaxy collection uninstall namespace1.name1 -y {{ galaxy_verbosity }} + register: not_installed + +- name: Install a collection + command: ansible-galaxy collection install namespace1.name1 {{ galaxy_verbosity }} + +- name: Uninstall the collection + command: ansible-galaxy collection uninstall namespace1.name1 -y {{ galaxy_verbosity }} + register: uninstalled + +- name: Install a specific version + command: ansible-galaxy collection install namespace1.name1:1.1.0-beta.1 {{ galaxy_verbosity }} + +- name: Uninstall a different version + command: ansible-galaxy collection uninstall -y namespace1.name1:1.0.0 -y {{ galaxy_verbosity }} + register: not_installed_2 + +- name: Uninstall a compatible requirement + command: ansible-galaxy collection uninstall -y namespace1.name1:>1.0.0 -y {{ galaxy_verbosity }} + register: uninstalled_beta + +- assert: + that: + - '"Uninstalled collection" not in not_installed.stdout' + - '"Uninstalled collection namespace1.name1:1.0.9" in uninstalled.stdout' + - '"Uninstalled collection" not in not_installed_2.stdout' + - '"Uninstalled collection namespace1.name1:1.1.0-beta.1" in uninstalled_beta.stdout' + +- name: Download a collection + command: ansible-galaxy collection download namespace1.name1 -p {{ galaxy_dir }}/artifacts {{ galaxy_verbosity }} + +- name: Initialize a role + command: ansible-galaxy role init test.role --init-path {{ galaxy_dir }}/roles_src {{ galaxy_verbosity }} + +- name: Create installable role + command: tar -czvf test.role.tar.gz test.role + args: + chdir: "{{ galaxy_dir }}/roles_src" + +- name: Create a requirements file to test uninstalling roles and collections + copy: + dest: "{{ galaxy_dir }}/uninstall_requirements.yml" + content: | + collections: + - name: parent_dep.parent_collection + version: <=2.0.0 + - name: {{ galaxy_dir }}/artifacts/namespace1-name1-1.0.9.tar.gz + roles: + - src: "file://{{ galaxy_dir }}/roles_src/test.role.tar.gz" + +- name: Install requirements file + command: ansible-galaxy install -r {{ galaxy_dir }}/uninstall_requirements.yml {{ galaxy_verbosity }} + +- name: Uninstall requirements file + command: ansible-galaxy uninstall -r {{ galaxy_dir }}/uninstall_requirements.yml -y {{ galaxy_verbosity }} + register: uninstall_requirements + +- assert: + that: + - '"Uninstalled collection parent_dep.parent_collection:2.0.0" in uninstall_requirements.stdout' + - '"Uninstalled collection namespace1.name1:1.0.9" in uninstall_requirements.stdout' + - '"Uninstalled role test.role (unknown version)" in uninstall_requirements.stdout' + # Dependencies are not removed + - '"Uninstalled collection child_dep.child_collection:1.0.0" not in uninstall_requirements.stdout' + - '"Uninstalled collection child_dep.child_dep2:1.2.2" not in uninstall_requirements.stdout' + +- name: Uninstall remaining dependencies + command: ansible-galaxy collection uninstall child_dep.child_collection child_dep.child_dep2 -y {{ galaxy_verbosity }} + register: uninstall_dependencies + +- assert: + that: + - '"Uninstalled collection child_dep.child_collection:1.0.0" in uninstall_dependencies.stdout' + - '"Uninstalled collection child_dep.child_dep2:1.2.2" in uninstall_dependencies.stdout' + +- name: Remove uninstall test files + file: + path: "{{ galaxy_dir }}/{{ item }}" + state: absent + loop: + - uninstall_requirements.yml + - roles_src + - roles From 4d2b7169c7423b7790e43f45a42120dc3350db64 Mon Sep 17 00:00:00 2001 From: s-hertel <19572925+s-hertel@users.noreply.github.com> Date: Mon, 24 Feb 2025 10:50:13 -0500 Subject: [PATCH 2/4] fix typo, missing return --- changelogs/fragments/add-ansible-galaxy-uninstall.yml | 2 +- lib/ansible/galaxy/collection/__init__.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/changelogs/fragments/add-ansible-galaxy-uninstall.yml b/changelogs/fragments/add-ansible-galaxy-uninstall.yml index 111a70eef43..20ba662e006 100644 --- a/changelogs/fragments/add-ansible-galaxy-uninstall.yml +++ b/changelogs/fragments/add-ansible-galaxy-uninstall.yml @@ -3,4 +3,4 @@ minor_changes: Added simple ``uninstall`` functionality to ``ansible-galaxy``. Unless ``--yes``/``-y`` is provided, paths will only be removed after user confirmation. Provide a ``--requirements-file`` to uninstall roles and collections at the same time. - Dependencies are not uninstalled, only direct requsts. + Dependencies are not uninstalled, only direct requests. diff --git a/lib/ansible/galaxy/collection/__init__.py b/lib/ansible/galaxy/collection/__init__.py index 70398e561c1..f12c740575e 100644 --- a/lib/ansible/galaxy/collection/__init__.py +++ b/lib/ansible/galaxy/collection/__init__.py @@ -997,6 +997,7 @@ def uninstall_collections(requirements: list[Requirement], search_paths: list[st rc = 0 for label, paths in collection_paths.items(): rc = _uninstall_paths(label, paths) or rc + return rc @contextmanager From 5241c7c430a543d1a03b6305a821120e83b793cb Mon Sep 17 00:00:00 2001 From: s-hertel <19572925+s-hertel@users.noreply.github.com> Date: Mon, 24 Feb 2025 11:33:40 -0500 Subject: [PATCH 3/4] add warning for previously uninstalled requirements, similar to pip report number of matching collections/roles found plugin loader already reports existing collections with -vvv --- lib/ansible/galaxy/collection/__init__.py | 5 +++++ lib/ansible/galaxy/role.py | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/lib/ansible/galaxy/collection/__init__.py b/lib/ansible/galaxy/collection/__init__.py index f12c740575e..9d4149d9613 100644 --- a/lib/ansible/galaxy/collection/__init__.py +++ b/lib/ansible/galaxy/collection/__init__.py @@ -971,7 +971,12 @@ def uninstall_collections(requirements: list[Requirement], search_paths: list[st ): if requirement.ver in (None, "*") or meets_requirements(installed.ver, requirement.ver): collections.append(installed) + if not collections or collections[-1].fqcn != requirement.fqcn or ( + requirement.ver not in (None, "*") and not meets_requirements(collections[-1].ver, requirement.ver) + ): + display.warning(f"Skipping collection {requirement} as it is not installed.") + display.display(f"Found {len(collections)} collections to remove.") remove = set() keep = set() collection_paths = {} diff --git a/lib/ansible/galaxy/role.py b/lib/ansible/galaxy/role.py index ec0228680e0..84c4a380d51 100644 --- a/lib/ansible/galaxy/role.py +++ b/lib/ansible/galaxy/role.py @@ -493,7 +493,12 @@ def uninstall_roles(requirements: list[GalaxyRole], search_paths: list[str], ski gr = GalaxyRole(requirement.galaxy, requirement.api, role_name, requirement.src, requirement.version, requirement.scm, path) if requirement.version is None or (gr.install_info or {}).get("version") == requirement.version: roles.append(gr) + if not roles or roles[-1].name != requirement.name or ( + requirement.version is not None and (roles[-1].install_info or {}).get("version") != requirement.version + ): + display.warning(f"Skipping role {requirement.name} ({requirement.version or 'unknown version'}) as it is not installed.") + display.display(f"Found {len(roles)} roles to remove.") remove = set() keep = set() role_paths = {} From 5f1422ab3143f29a67b8d9c1fded52315af9727f Mon Sep 17 00:00:00 2001 From: s-hertel <19572925+s-hertel@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:28:24 -0500 Subject: [PATCH 4/4] add ansible-galaxy uninstall description, fix --help --- lib/ansible/cli/galaxy.py | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/lib/ansible/cli/galaxy.py b/lib/ansible/cli/galaxy.py index 31a6fe37ff6..76dac1cb41a 100755 --- a/lib/ansible/cli/galaxy.py +++ b/lib/ansible/cli/galaxy.py @@ -381,8 +381,22 @@ class GalaxyCLI(CLI): def add_uninstall_options(self, parser, parents=None): galaxy_type = "role" if parser.metavar == "ROLE_ACTION" else "collection" - uninstall_parser = parser.add_parser("uninstall", parents=parents or [], - help=f"Uninstall collections from {C.COLLECTIONS_PATHS} and roles from {C.DEFAULT_ROLES_PATH}") + if self._implicit_role: + description = ( + f"Uninstall roles by name, or roles and collections together by providing a --requirements-file. " + f"Roles will be uninstalled from the configured role paths: {', '.join(C.DEFAULT_ROLES_PATH)}. " + f"Collections will be uninstalled from the configured collection paths: {', '.join(C.COLLECTIONS_PATHS)}. " + ) + else: + if galaxy_type == "role": + description = f"Uninstall roles from the configured role paths: {', '.join(C.DEFAULT_ROLES_PATH)}. " + else: + description = f"Uninstall collections from the configured collection paths: {', '.join(C.COLLECTIONS_PATHS)}. " + description += f"To remove all versions of a {galaxy_type}, only specify a {galaxy_type} name. " + + uninstall_parser = parser.add_parser("uninstall", parents=parents, description=description + + "This is a destructive operation and cannot be reversed.", + help=f"Uninstall {galaxy_type}s.") if galaxy_type == "role": uninstall_parser.add_argument("--roles-path", dest="roles_path", default=C.DEFAULT_ROLES_PATH, @@ -393,14 +407,18 @@ class GalaxyCLI(CLI): type=opt_help.unfrack_path(pathsep=True), action=opt_help.PrependListAction, help="Remove collections from additional paths.") + if self._implicit_role: + galaxy_types = "roles and collections" + else: + galaxy_types = f"{galaxy_type}s" + # one required, mutually exclusive - uninstall_parser.add_argument("args", metavar=f"{galaxy_type}_name", nargs="*", help=f"{galaxy_type.upper()} to uninstall") - uninstall_parser.add_argument("-r", "--requirements-file", dest="requirements", - help="A file containing a list of collections to be installed.") + uninstall_parser.add_argument("args", metavar=f"{galaxy_type}_name", nargs="*", help=f"{galaxy_type.upper()} to uninstall.") + uninstall_parser.add_argument("-r", "--requirements-file", dest="requirements", help=f"A file containing a list of {galaxy_types} to uninstall.") # bypass prompting uninstall_parser.add_argument("-y", "--yes", dest="yes", action="store_true", default=False, - help=f"Remove all {galaxy_type}s matching the requirements without prompting") + help=f"Remove all {galaxy_types} matching the requirements without prompting.") uninstall_parser.set_defaults(func=self.execute_uninstall)