Repository: pypa/pip

Issue:
AttributeError: 'NoneType' object has no attribute 'is_hash_allowed'
**Environment**

* pip version: 19.2
* Python version: 3.6.8
* OS: Mac OSX (Darwin Kernel Version 18.6.0)

**Description**
I made env update in my project including pip as well. After that I wanted to check outdated packages with command:

```python
pip list --outdated --format=columns
```

After that exception was raised.

**Expected behavior**
I expected list of packages or empty list.

**How to Reproduce**
1. Get the newest version of package from PyPI.
2. Then run `pip list --outdated --format=columns`
3. An error occurs.

**Output**
```python
(env) project (develop) $ pip list --outdated --format=columns
ERROR: Exception:
Traceback (most recent call last):
  File "/project/env/lib/python3.6/site-packages/pip/_internal/cli/base_command.py", line 188, in main
    status = self.run(options, args)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/commands/list.py", line 156, in run
    packages = self.get_outdated(packages, options)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/commands/list.py", line 164, in get_outdated
    dist for dist in self.iter_packages_latest_infos(packages, options)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/commands/list.py", line 164, in <listcomp>
    dist for dist in self.iter_packages_latest_infos(packages, options)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/commands/list.py", line 195, in iter_packages_latest_infos
    best_candidate = evaluator.get_best_candidate(all_candidates)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/index.py", line 729, in get_best_candidate
    best_candidate = max(candidates, key=self._sort_key)
  File "/project/env/lib/python3.6/site-packages/pip/_internal/index.py", line 710, in _sort_key
    has_allowed_hash = int(link.is_hash_allowed(self._hashes))
  File "/project/env/lib/python3.6/site-packages/pip/_internal/models/link.py", line 213, in is_hash_allowed
    return hashes.is_hash_allowed(self.hash_name, hex_digest=self.hash)
AttributeError: 'NoneType' object has no attribute 'is_hash_allowed'
```


Discussion:
@ptynecki Thanks for filing this issue!

/cc @cjerdonek since he's worked on this part of the codebase and is more familiar with it than me.
Same issue on Windows 10, Python 3.7.3
Same issue on Ubuntu 18.04.2 LTS, Python 3.6.8
Same issue on macOS 18.6.0 and Python 3.7.4
Same issue on Windows 10, Python 3.7.4
Same issue on Fedora 29, Python 3.7.3.

For now, as a workaround, ...
```bash
python3 -m pip install -UI --user  'pip<19.2'
```
reinstalled `pip` version 19.1.1. Although, the complete command output is curious.

```bash
$ python3 -m pip install -UI --user  'pip<19.2'
Collecting pip<19.2
  Using cached https://files.pythonhosted.org/packages/5c/e0/be401c003291b56efc55aeba6a80ab790d3d4cece2778288d65323009420/pip-19.1.1-py2.py3-none-any.whl
Installing collected packages: pip
Successfully installed pip-19.2
```
Why does it report `Successfully installed pip-19.2`?
Thanks for the confirmation of this occurring on multiple OSes!

Folks, if you're facing the same issue, please don't post additional comments. Please use GitHub reactions to upvote the first post and subscribe to the issue.

That way the maintainers would be able to have a discussion on how to resolve this in this issue -- additional "me too" comments won't help anyone.

Hmm, I guess the type checker failed us here. The type annotation says `hashes` must be non-None, but it is None in the reported cases: https://github.com/pypa/pip/blob/369ec7c0a8a81a076ca5584c34c8530d7a30a220/src/pip/_internal/models/link.py#L202-L209


After a quick look, I think a good fix for now may be to update `Link.is_hash_allowed(hashes)` to return `False` if `hashes` is None (and bring the annotation into alignment by updating the parameter to `Optional[Hashes]`). That's probably simpler and more sure-fire at this point than trying to update things in possibly multiple places to ensure that the `hashes` argument is always non-`None`, especially since we can't seem to lean on the type checker to confirm with 100% certainty.
> I guess the type checker failed us here.

Ahhh. Yep -- strict_optional is False for `pip._internal.index` and this is exactly that failure.

> we can't seem to lean on the type checker to confirm with 100% certainty.

Yea, we can't completely rely on it, as long as we have these flags for incremental adoption.

> Ahhh. Yep -- strict_optional is False for pip._internal.index and this is exactly that failure.

And even though `models/link.py` where the function is defined does have `strict_optional` enabled.

Resolving patch:
diff --git a/src/pip/_internal/models/link.py b/src/pip/_internal/models/link.py
--- a/src/pip/_internal/models/link.py
+++ b/src/pip/_internal/models/link.py
@@ -200,11 +200,11 @@ def has_hash(self):
         return self.hash_name is not None
 
     def is_hash_allowed(self, hashes):
-        # type: (Hashes) -> bool
+        # type: (Optional[Hashes]) -> bool
         """
         Return True if the link has a hash and it is allowed.
         """
-        if not self.has_hash:
+        if hashes is None or not self.has_hash:
             return False
         # Assert non-None so mypy knows self.hash_name and self.hash are str.
         assert self.hash_name is not None


Test patch:
diff --git a/tests/unit/test_link.py b/tests/unit/test_link.py
--- a/tests/unit/test_link.py
+++ b/tests/unit/test_link.py
@@ -117,3 +117,13 @@ def test_is_hash_allowed__no_hash(self):
         }
         hashes = Hashes(hashes_data)
         assert not link.is_hash_allowed(hashes)
+
+    @pytest.mark.parametrize('hashes, expected', [
+        (None, False),
+        # Also test a success case to show the test is correct.
+        (Hashes({'sha512': [128 * 'a']}), True),
+    ])
+    def test_is_hash_allowed__none_hashes(self, hashes, expected):
+        url = 'https://example.com/wheel.whl#sha512={}'.format(128 * 'a')
+        link = Link(url)
+        assert link.is_hash_allowed(hashes) == expected