Repository: pypa/pip

Issue:
`AttributeError: type object '_CleanResult' has no attribute 'from_link'` crash on v21.2.3
### Description

`AttributeError` raised during `pip install -r requirements.txt`

### Expected behavior

Installation should not raise error

### pip version

21.2.3

### Python version

3.6.14

### OS

Docker image `python:3.6.0 sha256:a1782fa44ef7b53a653d51e20b961b90847ee6b6b95eeba619db8aa8b868e440`

### How to Reproduce

1. Install pip 21.2.3 globally: `pip install -U pip==21.2.3`
2. Run `pip install -r requirements.txt`. `requirements.txt` looks like:
```
Django==3.0.2
psycopg2-binary==2.8.4
mysqlclient==1.4.6
ujson==2.0.2
uWSGI==2.0.18
jsonschema==3.2.0
pyotp==2.3.0
qrcode[pil]==6.1
filelock==3.0.12
pyparsing==2.4.6
validate-email==1.3
python-dateutil==2.8.1
password-hashing-python==0.3.0
quick-email==0.5.0
```
3. `AttributeError` is raised

I wish I could provide more than that.

### Output

```sh-session
ERROR: Exception:
Traceback (most recent call last):
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/cli/base_command.py", line 173, in _main
    status = self.run(options, args)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/cli/req_command.py", line 203, in wrapper
    return func(self, options, args)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/commands/install.py", line 316, in run
    reqs, check_supported_wheels=not options.target_dir
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/resolution/resolvelib/resolver.py", line 95, in resolve
    collected.requirements, max_rounds=try_to_avoid_resolution_too_deep
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/resolvers.py", line 472, in resolve
    state = resolution.resolve(requirements, max_rounds=max_rounds)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/resolvers.py", line 366, in resolve
    failure_causes = self._attempt_to_pin_criterion(name)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/resolvers.py", line 212, in _attempt_to_pin_criterion
    criteria = self._get_updated_criteria(candidate)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/resolvers.py", line 203, in _get_updated_criteria
    self._add_to_criteria(criteria, requirement, parent=candidate)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/resolvers.py", line 168, in _add_to_criteria
    candidates=build_iter_view(matches),
  File "/builds/.venv/lib/python3.6/site-packages/pip/_vendor/resolvelib/structs.py", line 164, in build_iter_view
    matches = list(matches)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/resolution/resolvelib/factory.py", line 417, in <genexpr>
    and all(req.is_satisfied_by(c) for req in requirements[identifier])
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/resolution/resolvelib/factory.py", line 417, in <genexpr>
    and all(req.is_satisfied_by(c) for req in requirements[identifier])
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/resolution/resolvelib/requirements.py", line 39, in is_satisfied_by
    return candidate == self.candidate
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/resolution/resolvelib/candidates.py", line 172, in __eq__
    return links_equivalent(self._link, other._link)
  File "/builds/.venv/lib/python3.6/site-packages/pip/_internal/models/link.py", line 288, in links_equivalent
    return _CleanResult.from_link(link1) == _CleanResult.from_link(link2)
AttributeError: type object '_CleanResult' has no attribute 'from_link'
```


### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).


Discussion:
Thanks for the report. This looks like yet another Python patch-level typing incompatibility.

https://docs.python.org/3.6/library/typing.html#typing.NamedTuple

> *Changed in version 3.6.1:* Added support for default values, methods, and docstrings.

So everything *other than* 3.6.0 is fine, which is why we didn’t get this earlier. I hope Python never does this again, this 3.6.x compatibility level thing is painful.

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
@@ -256,33 +256,33 @@ class _CleanResult(NamedTuple):
     subdirectory: str
     hashes: Dict[str, str]
 
-    @classmethod
-    def from_link(cls, link: Link) -> "_CleanResult":
-        parsed = link._parsed_url
-        netloc = parsed.netloc.rsplit("@", 1)[-1]
-        # According to RFC 8089, an empty host in file: means localhost.
-        if parsed.scheme == "file" and not netloc:
-            netloc = "localhost"
-        fragment = urllib.parse.parse_qs(parsed.fragment)
-        if "egg" in fragment:
-            logger.debug("Ignoring egg= fragment in %s", link)
-        try:
-            # If there are multiple subdirectory values, use the first one.
-            # This matches the behavior of Link.subdirectory_fragment.
-            subdirectory = fragment["subdirectory"][0]
-        except (IndexError, KeyError):
-            subdirectory = ""
-        # If there are multiple hash values under the same algorithm, use the
-        # first one. This matches the behavior of Link.hash_value.
-        hashes = {k: fragment[k][0] for k in _SUPPORTED_HASHES if k in fragment}
-        return cls(
-            parsed=parsed._replace(netloc=netloc, query="", fragment=""),
-            query=urllib.parse.parse_qs(parsed.query),
-            subdirectory=subdirectory,
-            hashes=hashes,
-        )
+
+def _clean_link(link: Link) -> _CleanResult:
+    parsed = link._parsed_url
+    netloc = parsed.netloc.rsplit("@", 1)[-1]
+    # According to RFC 8089, an empty host in file: means localhost.
+    if parsed.scheme == "file" and not netloc:
+        netloc = "localhost"
+    fragment = urllib.parse.parse_qs(parsed.fragment)
+    if "egg" in fragment:
+        logger.debug("Ignoring egg= fragment in %s", link)
+    try:
+        # If there are multiple subdirectory values, use the first one.
+        # This matches the behavior of Link.subdirectory_fragment.
+        subdirectory = fragment["subdirectory"][0]
+    except (IndexError, KeyError):
+        subdirectory = ""
+    # If there are multiple hash values under the same algorithm, use the
+    # first one. This matches the behavior of Link.hash_value.
+    hashes = {k: fragment[k][0] for k in _SUPPORTED_HASHES if k in fragment}
+    return _CleanResult(
+        parsed=parsed._replace(netloc=netloc, query="", fragment=""),
+        query=urllib.parse.parse_qs(parsed.query),
+        subdirectory=subdirectory,
+        hashes=hashes,
+    )
 
 
 @functools.lru_cache(maxsize=None)
 def links_equivalent(link1: Link, link2: Link) -> bool:
-    return _CleanResult.from_link(link1) == _CleanResult.from_link(link2)
+    return _clean_link(link1) == _clean_link(link2)


Test patch: