Repository: ansible/ansible
Pull request: Improve ansible-galaxy error for InconsistentCandidate exception
##### SUMMARY
Fixes #75139

##### ISSUE TYPE
- Bugfix Pull Request


Problem statement:
Installing collection artifact with mismatch version results in exception
### Summary

When installing a collection artifact from a URL or tarball, with a mismatched `version`, an exception is raised, that should be handled and a better message provided to the user.

### Issue Type

Bug Report

### Component Name
```
lib/ansible/galaxy/collection/__init__.py
```
### Ansible Version

```console
$ ansible --version
2.11+
```


### Configuration

```console
$ ansible-config dump --only-changed
```


### OS / Environment

N/A

### Steps to Reproduce

<!--- Paste example playbooks or commands between quotes below -->
```shell
ansible-galaxy collection download -p . --no-deps sivel.toiletwater:==0.0.10
sed -i 's/version: 0.0.10/version: 0.0.9/' requirements.yml
ansible-galaxy collection install -p . -r requirements.yml -vvv
```


### Expected Results

Friendly error message

### Actual Results

```console
ERROR! Unexpected Exception, this is probably a bug: Provided candidate <sivel.toiletwater:0.0.10 of type 'file' from sivel-toiletwater-0.0.10.tar.gz> does not satisfy <sivel.toiletwater:0.0.9 of type 'file' from sivel-toiletwater-0.0.10.tar.gz>
the full traceback was:

Traceback (most recent call last):
  File "/Users/matt/projects/ansibledev/ansible/bin/ansible-galaxy", line 133, in <module>
    exit_code = cli.run()
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/cli/galaxy.py", line 552, in run
    return context.CLIARGS['func']()
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/cli/galaxy.py", line 75, in method_wrapper
    return wrapped_method(*args, **kwargs)
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/cli/galaxy.py", line 1186, in execute_install
    self._execute_install_collection(
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/cli/galaxy.py", line 1213, in _execute_install_collection
    install_collections(
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/galaxy/collection/__init__.py", line 512, in install_collections
    dependency_map = _resolve_depenency_map(
  File "/Users/matt/projects/ansibledev/ansible/lib/ansible/galaxy/collection/__init__.py", line 1329, in _resolve_depenency_map
    return collection_dep_resolver.resolve(
  File "/Users/matt/venvs/ansibledev/lib/python3.9/site-packages/resolvelib/resolvers.py", line 453, in resolve
    state = resolution.resolve(requirements, max_rounds=max_rounds)
  File "/Users/matt/venvs/ansibledev/lib/python3.9/site-packages/resolvelib/resolvers.py", line 347, in resolve
    failure_causes = self._attempt_to_pin_criterion(name, criterion)
  File "/Users/matt/venvs/ansibledev/lib/python3.9/site-packages/resolvelib/resolvers.py", line 221, in _attempt_to_pin_criterion
    raise InconsistentCandidate(candidate, criterion)
resolvelib.resolvers.InconsistentCandidate: Provided candidate <sivel.toiletwater:0.0.10 of type 'file' from sivel-toiletwater-0.0.10.tar.gz> does not satisfy <sivel.toiletwater:0.0.9 of type 'file' from sivel-toiletwater-0.0.10.tar.gz>
```


### Code of Conduct

- [X] I agree to follow the Ansible Code of Conduct

Hints from issue discussion:
Files identified in the description:
* [`lib/ansible/galaxy/collection/__init__.py`](https://github.com/ansible/ansible/blob/devel/lib/ansible/galaxy/collection/__init__.py)

If these files are incorrect, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->
An example of this in the wild
requirements file:
```
---
collections:
  - name: https://gitlab.company.com/api/v4/projects/15/repository/files/awx-awx-19.2.1-devela.tar.gz/raw?ref=master
    version: 19.2.1
```
error:
```


Starting galaxy collection install process
Process install dependency map
ERROR! Unexpected Exception, this is probably a bug: Provided candidate <awx.awx:19.2.1-devela of type 'url' from https://gitlab.company.com/api/v4/projects/15/repository/files/awx-awx-19.2.1-devela.tar.gz/raw> does not satisfy <awx.awx:19.2.1 of type 'url' from https://gitlab.company.com/api/v4/projects/15/repository/files/awx-awx-19.2.1-devela.tar.gz/raw>
the full traceback was:
Traceback (most recent call last):
  File "/usr/local/bin/ansible-galaxy", line 135, in <module>
    exit_code = cli.run()
  File "/usr/local/lib/python3.8/site-packages/ansible/cli/galaxy.py", line 552, in run
    return context.CLIARGS['func']()
  File "/usr/local/lib/python3.8/site-packages/ansible/cli/galaxy.py", line 75, in method_wrapper
    return wrapped_method(*args, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/ansible/cli/galaxy.py", line 1186, in execute_install
    self._execute_install_collection(
  File "/usr/local/lib/python3.8/site-packages/ansible/cli/galaxy.py", line 1213, in _execute_install_collection
    install_collections(
  File "/usr/local/lib/python3.8/site-packages/ansible/galaxy/collection/__init__.py", line 511, in install_collections
    dependency_map = _resolve_depenency_map(
  File "/usr/local/lib/python3.8/site-packages/ansible/galaxy/collection/__init__.py", line 1328, in _resolve_depenency_map
    return collection_dep_resolver.resolve(
  File "/usr/local/lib/python3.8/site-packages/resolvelib/resolvers.py", line 453, in resolve
    state = resolution.resolve(requirements, max_rounds=max_rounds)
  File "/usr/local/lib/python3.8/site-packages/resolvelib/resolvers.py", line 347, in resolve
    failure_causes = self._attempt_to_pin_criterion(name, criterion)
  File "/usr/local/lib/python3.8/site-packages/resolvelib/resolvers.py", line 221, in _attempt_to_pin_criterion
    raise InconsistentCandidate(candidate, criterion)
resolvelib.resolvers.InconsistentCandidate: Provided candidate <awx.awx:19.2.1-devela of type 'url' from https://gitlab.company.com/api/v4/projects/15/repository/files/awx-awx-19.2.1-devela.tar.gz/raw> does not satisfy <awx.awx:19.2.1 of type 'url' from https://gitlab.company.com/api/v4/projects/15/repository/files/awx-awx-19.2.1-devela.tar.gz/raw?ref=master>
```

Commit message: Update changelogs/fragments/75235-ansible-galaxy-inconsistent-candidate-error.yml

Co-authored-by: Abhijeet Kasurde <akasurde@redhat.com>

Patch to review:
diff --git a/changelogs/fragments/75235-ansible-galaxy-inconsistent-candidate-error.yml b/changelogs/fragments/75235-ansible-galaxy-inconsistent-candidate-error.yml
new file mode 100644
index 00000000000000..e56d524961af42
--- /dev/null
+++ b/changelogs/fragments/75235-ansible-galaxy-inconsistent-candidate-error.yml
@@ -0,0 +1,2 @@
+bugfixes:
+  - ansible-galaxy - Improve error message from dependency resolution when a candidate has inconsistent requirements (https://github.com/ansible/ansible/issues/75139).
diff --git a/lib/ansible/galaxy/collection/__init__.py b/lib/ansible/galaxy/collection/__init__.py
index 9af3068c8e86b1..460dabbf339850 100644
--- a/lib/ansible/galaxy/collection/__init__.py
+++ b/lib/ansible/galaxy/collection/__init__.py
@@ -113,6 +113,7 @@
 )
 from ansible.galaxy.dependency_resolution.errors import (
     CollectionDependencyResolutionImpossible,
+    CollectionDependencyInconsistentCandidate,
 )
 from ansible.galaxy.dependency_resolution.versioning import meets_requirements
 from ansible.module_utils.six import raise_from
@@ -1353,3 +1354,29 @@ def _resolve_depenency_map(
             AnsibleError('\n'.join(error_msg_lines)),
             dep_exc,
         )
+    except CollectionDependencyInconsistentCandidate as dep_exc:
+        parents = [p for p in dep_exc.criterion.iter_parent() if p is not None]
+
+        error_msg_lines = [
+            (
+                'Failed to resolve the requested dependencies map. '
+                'Got the candidate {req.fqcn!s}:{req.ver!s} ({dep_origin!s}) '
+                'which didn\'t satisfy all of the following requirements:'.
+                format(
+                    req=dep_exc.candidate,
+                    dep_origin='direct request'
+                    if not parents else 'dependency of {parent!s}'.
+                    format(parent=','.join(parents))
+                )
+            )
+        ]
+
+        for req in dep_exc.criterion.iter_requirement():
+            error_msg_lines.append(
+                '* {req.fqcn!s}:{req.ver!s}'.format(req=req)
+            )
+
+        raise raise_from(  # NOTE: Leading "raise" is a hack for mypy bug #9717
+            AnsibleError('\n'.join(error_msg_lines)),
+            dep_exc,
+        )
diff --git a/lib/ansible/galaxy/dependency_resolution/errors.py b/lib/ansible/galaxy/dependency_resolution/errors.py
index e57bd06e5723a7..f5339a4d519be6 100644
--- a/lib/ansible/galaxy/dependency_resolution/errors.py
+++ b/lib/ansible/galaxy/dependency_resolution/errors.py
@@ -8,4 +8,5 @@
 
 from resolvelib.resolvers import (
     ResolutionImpossible as CollectionDependencyResolutionImpossible,
+    InconsistentCandidate as CollectionDependencyInconsistentCandidate,
 )
diff --git a/test/integration/targets/ansible-galaxy-collection/tasks/install.yml b/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
index 44c2fcd99fc39b..90a0fc50a528c6 100644
--- a/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
+++ b/test/integration/targets/ansible-galaxy-collection/tasks/install.yml
@@ -163,6 +163,36 @@
     - '"Installing ''namespace3.name:1.0.0'' to" in install_tarball.stdout'
     - (install_tarball_actual.content | b64decode | from_json).collection_info.version == '1.0.0'
 
+- name: write a requirements file using the artifact and a conflicting version
+  copy:
+    content: |
+            collections:
+              - name: {{ galaxy_dir }}/namespace3.tar.gz
+                version: 1.2.0
+    dest: '{{ galaxy_dir }}/test_req.yml'
+
+- name: install the requirements file with mismatched versions
+  command: ansible-galaxy collection install -r '{{ galaxy_dir }}/test_req.yml' {{ galaxy_verbosity }}
+  ignore_errors: True
+  register: result
+
+- name: remove the requirements file
+  file:
+    path: '{{ galaxy_dir }}/test_req.yml'
+    state: absent
+
+- assert:
+    that: error == expected_error
+  vars:
+    reset_color: '\x1b\[0m'
+    color: '\x1b\[[0-9];[0-9]{2}m'
+    error: "{{ result.stderr | regex_replace(reset_color) | regex_replace(color) | regex_replace('\\n', ' ') }}"
+    expected_error: >-
+            ERROR! Failed to resolve the requested dependencies map.
+            Got the candidate namespace3.name:1.0.0 (direct request)
+            which didn't satisfy all of the following requirements:
+            * namespace3.name:1.2.0
+
 - name: setup bad tarball - {{ test_name }}
   script: build_bad_tar.py {{ galaxy_dir | quote }}
 


Code review comments:
diff_hunk: @@ -1353,3 +1354,29 @@ def _resolve_depenency_map(
             AnsibleError('\n'.join(error_msg_lines)),
             dep_exc,
         )
+    except CollectionDependencyInconsistentCandidate as dep_exc:
+        parents = [p for p in dep_exc.criterion.iter_parent() if p is not None]
+
+        error_msg_lines = [
+            (
+                'Failed to resolve the requested dependencies map. '
+                'Got the candidate {req.fqcn!s}:{req.ver!s} ({dep_origin!s}) '
+                'which didn\'t satisfy all of the following requirements:'.
+                format(
+                    req=dep_exc.candidate,
+                    dep_origin='direct request'
+                    if not parents else 'dependency of {parent!s}'.
+                    format(parent=','.join(parents))
path: lib/ansible/galaxy/collection/__init__.py
text: @user1:
How about having a whitespace after the commas?
```suggestion
                    if not parents else 'dependency of {parents!s}'.
                    format(parents=', '.join(parents))
```