We are currently solving the following issue within our repository. Here is the issue text:
--- BEGIN ISSUE ---
pip 23.0: `pip list` fails with error "ValueError: not enough values to unpack" for archives installed from URL
### Description

When a project contains installed archives installed by specifying URL (e.g. spaCy models), `pip list` does not display the list of installed packages, because it crashes.

Probable cause: https://github.com/pypa/pip/commit/38681f3d6669754c7e919f0eb051b12931bfb0f2

### Expected behavior

_No response_

### pip version

pip 23.0

### Python version

Python 3.8.16

### OS

Ubuntu 20.04.5 LTS (Focal Fossa)

### How to Reproduce

```
pyenv virtualenv 3.8.16 pip-issue
pyenv local pip-issue
```

```
cat << EOF > ./pyproject.toml
[tool.poetry]
name = "pip_issue"
version = "0.0.1"
authors = [ "Foo Bar <foo@example.com>" ]
description = "pip issue"

[tool.poetry.dependencies]
python = ">=3.8, <3.11"
en_core_web_sm = { url = "https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.5.0/en_core_web_sm-3.5.0.tar.gz" }

[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
EOF
```

```
pip install --upgrade poetry
poetry install
```

```
# ✅ previous version works fine
pip list  
```

```
# 💥 latest version crashes
python3.8 -m pip install --upgrade pip
pip list
```

### Output

```
$ pip list                              
ERROR: Exception:
Traceback (most recent call last):
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/cli/base_command.py", line 160, in exc_logging_wrapper
    status = run_func(*args)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py", line 192, in run
    self.output_package_listing(packages, options)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py", line 273, in output_package_listing
    data, header = format_for_columns(packages, options)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py", line 316, in format_for_columns
    has_editables = any(x.editable for x in pkgs)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py", line 316, in <genexpr>
    has_editables = any(x.editable for x in pkgs)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py", line 338, in editable
    return bool(self.editable_project_location)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py", line 176, in editable_project_location
    direct_url = self.direct_url
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py", line 306, in direct_url
    return DirectUrl.from_json(content)
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py", line 217, in from_json
    return cls.from_dict(json.loads(s))
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py", line 200, in from_dict
    ArchiveInfo._from_dict(_get(d, dict, "archive_info")),
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py", line 124, in _from_dict
    return cls(hash=_get(d, str, "hash"), hashes=_get(d, dict, "hashes"))
  File "/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py", line 111, in __init__
    hash_name, hash_value = hash.split("=", 1)
ValueError: not enough values to unpack (expected 2, got 1)
```

### Code of Conduct

- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).

--- END ISSUE ---

Below are some code segments, each from a relevant file. One or more of these files may contain bugs.
--- BEGIN FILES ---
Path: `src/pip/_internal/models/direct_url.py`
Content:
```
1 """ PEP 610 """
2 import json
3 import re
4 import urllib.parse
5 from typing import Any, Dict, Iterable, Optional, Type, TypeVar, Union
6 
7 __all__ = [
8     "DirectUrl",
9     "DirectUrlValidationError",
10     "DirInfo",
11     "ArchiveInfo",
12     "VcsInfo",
13 ]
14 
15 T = TypeVar("T")
16 
17 DIRECT_URL_METADATA_NAME = "direct_url.json"
18 ENV_VAR_RE = re.compile(r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$")
19 
20 
21 class DirectUrlValidationError(Exception):
22     pass
23 
24 
25 def _get(
26     d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None
27 ) -> Optional[T]:
28     """Get value from dictionary and verify expected type."""
29     if key not in d:
30         return default
31     value = d[key]
32     if not isinstance(value, expected_type):
33         raise DirectUrlValidationError(
34             "{!r} has unexpected type for {} (expected {})".format(
35                 value, key, expected_type
36             )
37         )
38     return value
39 
40 
41 def _get_required(
42     d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None
43 ) -> T:
44     value = _get(d, expected_type, key, default)
45     if value is None:
46         raise DirectUrlValidationError(f"{key} must have a value")
47     return value
48 
49 
50 def _exactly_one_of(infos: Iterable[Optional["InfoType"]]) -> "InfoType":
51     infos = [info for info in infos if info is not None]
52     if not infos:
53         raise DirectUrlValidationError(
54             "missing one of archive_info, dir_info, vcs_info"
55         )
56     if len(infos) > 1:
57         raise DirectUrlValidationError(
58             "more than one of archive_info, dir_info, vcs_info"
59         )
60     assert infos[0] is not None
61     return infos[0]
62 
63 
64 def _filter_none(**kwargs: Any) -> Dict[str, Any]:
65     """Make dict excluding None values."""
66     return {k: v for k, v in kwargs.items() if v is not None}
67 
68 
69 class VcsInfo:
70     name = "vcs_info"
71 
72     def __init__(
73         self,
74         vcs: str,
75         commit_id: str,
76         requested_revision: Optional[str] = None,
77     ) -> None:
78         self.vcs = vcs
79         self.requested_revision = requested_revision
80         self.commit_id = commit_id
81 
82     @classmethod
83     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["VcsInfo"]:
84         if d is None:
85             return None
86         return cls(
87             vcs=_get_required(d, str, "vcs"),
88             commit_id=_get_required(d, str, "commit_id"),
89             requested_revision=_get(d, str, "requested_revision"),
90         )
91 
92     def _to_dict(self) -> Dict[str, Any]:
93         return _filter_none(
94             vcs=self.vcs,
95             requested_revision=self.requested_revision,
96             commit_id=self.commit_id,
97         )
98 
99 
100 class ArchiveInfo:
101     name = "archive_info"
102 
103     def __init__(
104         self,
105         hash: Optional[str] = None,
106         hashes: Optional[Dict[str, str]] = None,
107     ) -> None:
108         if hash is not None:
109             # Auto-populate the hashes key to upgrade to the new format automatically.
110             # We don't back-populate the legacy hash key.
111             hash_name, hash_value = hash.split("=", 1)
112             if hashes is None:
113                 hashes = {hash_name: hash_value}
114             elif hash_name not in hash:
115                 hashes = hashes.copy()
116                 hashes[hash_name] = hash_value
117         self.hash = hash
118         self.hashes = hashes
119 
120     @classmethod
121     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["ArchiveInfo"]:
122         if d is None:
123             return None
124         return cls(hash=_get(d, str, "hash"), hashes=_get(d, dict, "hashes"))
125 
126     def _to_dict(self) -> Dict[str, Any]:
127         return _filter_none(hash=self.hash, hashes=self.hashes)
128 
129 
130 class DirInfo:
131     name = "dir_info"
132 
133     def __init__(
134         self,
135         editable: bool = False,
136     ) -> None:
137         self.editable = editable
138 
139     @classmethod
140     def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional["DirInfo"]:
141         if d is None:
142             return None
143         return cls(editable=_get_required(d, bool, "editable", default=False))
144 
145     def _to_dict(self) -> Dict[str, Any]:
146         return _filter_none(editable=self.editable or None)
147 
148 
149 InfoType = Union[ArchiveInfo, DirInfo, VcsInfo]
150 
151 
152 class DirectUrl:
153     def __init__(
154         self,
155         url: str,
156         info: InfoType,
157         subdirectory: Optional[str] = None,
158     ) -> None:
159         self.url = url
160         self.info = info
161         self.subdirectory = subdirectory
162 
163     def _remove_auth_from_netloc(self, netloc: str) -> str:
164         if "@" not in netloc:
165             return netloc
166         user_pass, netloc_no_user_pass = netloc.split("@", 1)
167         if (
168             isinstance(self.info, VcsInfo)
169             and self.info.vcs == "git"
170             and user_pass == "git"
171         ):
172             return netloc
173         if ENV_VAR_RE.match(user_pass):
174             return netloc
175         return netloc_no_user_pass
176 
177     @property
178     def redacted_url(self) -> str:
179         """url with user:password part removed unless it is formed with
180         environment variables as specified in PEP 610, or it is ``git``
181         in the case of a git URL.
182         """
183         purl = urllib.parse.urlsplit(self.url)
184         netloc = self._remove_auth_from_netloc(purl.netloc)
185         surl = urllib.parse.urlunsplit(
186             (purl.scheme, netloc, purl.path, purl.query, purl.fragment)
187         )
188         return surl
189 
190     def validate(self) -> None:
191         self.from_dict(self.to_dict())
192 
193     @classmethod
194     def from_dict(cls, d: Dict[str, Any]) -> "DirectUrl":
195         return DirectUrl(
196             url=_get_required(d, str, "url"),
197             subdirectory=_get(d, str, "subdirectory"),
198             info=_exactly_one_of(
199                 [
200                     ArchiveInfo._from_dict(_get(d, dict, "archive_info")),
201                     DirInfo._from_dict(_get(d, dict, "dir_info")),
202                     VcsInfo._from_dict(_get(d, dict, "vcs_info")),
203                 ]
204             ),
205         )
206 
207     def to_dict(self) -> Dict[str, Any]:
208         res = _filter_none(
209             url=self.redacted_url,
210             subdirectory=self.subdirectory,
211         )
212         res[self.info.name] = self.info._to_dict()
213         return res
214 
215     @classmethod
216     def from_json(cls, s: str) -> "DirectUrl":
217         return cls.from_dict(json.loads(s))
218 
219     def to_json(self) -> str:
220         return json.dumps(self.to_dict(), sort_keys=True)
221 
222     def is_local_editable(self) -> bool:
223         return isinstance(self.info, DirInfo) and self.info.editable
224
```
--- END FILES ---

Please first localize the bug based on the issue statement, and then generate a patch according to the `git diff` format fenced by three backticks.

Here is an example:

```diff
diff --git a/examples/server_async.py b/examples/server_async.py
--- a/examples/server_async.py
+++ b/examples/server_async.py
@@ -313,4 +313,4 @@
 
 
 if __name__ == "__main__":
-    asyncio.run(run_async_server("."), debug=True)
+    asyncio.run(run_async_server(), debug=True)
diff --git a/examples/server_sync.py b/examples/server_sync.py
--- a/examples/server_sync.py
+++ b/examples/server_sync.py
@@ -313,5 +313,5 @@
 
 
 if __name__ == "__main__":
-    server = run_sync_server(".")
+    server = run_sync_server()
     server.shutdown()

```

diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py
--- a/src/pip/_internal/models/direct_url.py
+++ b/src/pip/_internal/models/direct_url.py
@@ -108,7 +108,12 @@
         if hash is not None:
             # Auto-populate the hashes key to upgrade to the new format automatically.
             # We don't back-populate the legacy hash key.
-            hash_name, hash_value = hash.split("=", 1)
+            try:
+                hash_name, hash_value = hash.split("=", 1)
+            except ValueError:
+                raise DirectUrlValidationError(
+                    f"invalid archive_info.hash format: {hash!r}"
+                )
             if hashes is None:
                 hashes = {hash_name: hash_value}
             elif hash_name not in hash:

{"golden_diff": "diff --git a/src/pip/_internal/models/direct_url.py b/src/pip/_internal/models/direct_url.py\n--- a/src/pip/_internal/models/direct_url.py\n+++ b/src/pip/_internal/models/direct_url.py\n@@ -108,7 +108,12 @@\n         if hash is not None:\n             # Auto-populate the hashes key to upgrade to the new format automatically.\n             # We don't back-populate the legacy hash key.\n-            hash_name, hash_value = hash.split(\"=\", 1)\n+            try:\n+                hash_name, hash_value = hash.split(\"=\", 1)\n+            except ValueError:\n+                raise DirectUrlValidationError(\n+                    f\"invalid archive_info.hash format: {hash!r}\"\n+                )\n             if hashes is None:\n                 hashes = {hash_name: hash_value}\n             elif hash_name not in hash:\n", "issue": "pip 23.0: `pip list` fails with error \"ValueError: not enough values to unpack\" for archives installed from URL\n### Description\r\n\r\nWhen a project contains installed archives installed by specifying URL (e.g. spaCy models), `pip list` does not display the list of installed packages, because it crashes.\r\n\r\nProbable cause: https://github.com/pypa/pip/commit/38681f3d6669754c7e919f0eb051b12931bfb0f2\r\n\r\n### Expected behavior\r\n\r\n_No response_\r\n\r\n### pip version\r\n\r\npip 23.0\r\n\r\n### Python version\r\n\r\nPython 3.8.16\r\n\r\n### OS\r\n\r\nUbuntu 20.04.5 LTS (Focal Fossa)\r\n\r\n### How to Reproduce\r\n\r\n```\r\npyenv virtualenv 3.8.16 pip-issue\r\npyenv local pip-issue\r\n```\r\n\r\n```\r\ncat << EOF > ./pyproject.toml\r\n[tool.poetry]\r\nname = \"pip_issue\"\r\nversion = \"0.0.1\"\r\nauthors = [ \"Foo Bar <foo@example.com>\" ]\r\ndescription = \"pip issue\"\r\n\r\n[tool.poetry.dependencies]\r\npython = \">=3.8, <3.11\"\r\nen_core_web_sm = { url = \"https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.5.0/en_core_web_sm-3.5.0.tar.gz\" }\r\n\r\n[build-system]\r\nrequires = [\"poetry-core>=1.0.0\"]\r\nbuild-backend = \"poetry.core.masonry.api\"\r\nEOF\r\n```\r\n\r\n```\r\npip install --upgrade poetry\r\npoetry install\r\n```\r\n\r\n```\r\n# \u2705 previous version works fine\r\npip list  \r\n```\r\n\r\n```\r\n# \ud83d\udca5 latest version crashes\r\npython3.8 -m pip install --upgrade pip\r\npip list\r\n```\r\n\r\n### Output\r\n\r\n```\r\n$ pip list                              \r\nERROR: Exception:\r\nTraceback (most recent call last):\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/cli/base_command.py\", line 160, in exc_logging_wrapper\r\n    status = run_func(*args)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py\", line 192, in run\r\n    self.output_package_listing(packages, options)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py\", line 273, in output_package_listing\r\n    data, header = format_for_columns(packages, options)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py\", line 316, in format_for_columns\r\n    has_editables = any(x.editable for x in pkgs)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/commands/list.py\", line 316, in <genexpr>\r\n    has_editables = any(x.editable for x in pkgs)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py\", line 338, in editable\r\n    return bool(self.editable_project_location)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py\", line 176, in editable_project_location\r\n    direct_url = self.direct_url\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/metadata/base.py\", line 306, in direct_url\r\n    return DirectUrl.from_json(content)\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py\", line 217, in from_json\r\n    return cls.from_dict(json.loads(s))\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py\", line 200, in from_dict\r\n    ArchiveInfo._from_dict(_get(d, dict, \"archive_info\")),\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py\", line 124, in _from_dict\r\n    return cls(hash=_get(d, str, \"hash\"), hashes=_get(d, dict, \"hashes\"))\r\n  File \"/home/users/foo/.pyenv/versions/pip-issue/lib/python3.8/site-packages/pip/_internal/models/direct_url.py\", line 111, in __init__\r\n    hash_name, hash_value = hash.split(\"=\", 1)\r\nValueError: not enough values to unpack (expected 2, got 1)\r\n```\r\n\r\n### Code of Conduct\r\n\r\n- [X] I agree to follow the [PSF Code of Conduct](https://www.python.org/psf/conduct/).\n", "before_files": [{"content": "\"\"\" PEP 610 \"\"\"\nimport json\nimport re\nimport urllib.parse\nfrom typing import Any, Dict, Iterable, Optional, Type, TypeVar, Union\n\n__all__ = [\n    \"DirectUrl\",\n    \"DirectUrlValidationError\",\n    \"DirInfo\",\n    \"ArchiveInfo\",\n    \"VcsInfo\",\n]\n\nT = TypeVar(\"T\")\n\nDIRECT_URL_METADATA_NAME = \"direct_url.json\"\nENV_VAR_RE = re.compile(r\"^\\$\\{[A-Za-z0-9-_]+\\}(:\\$\\{[A-Za-z0-9-_]+\\})?$\")\n\n\nclass DirectUrlValidationError(Exception):\n    pass\n\n\ndef _get(\n    d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None\n) -> Optional[T]:\n    \"\"\"Get value from dictionary and verify expected type.\"\"\"\n    if key not in d:\n        return default\n    value = d[key]\n    if not isinstance(value, expected_type):\n        raise DirectUrlValidationError(\n            \"{!r} has unexpected type for {} (expected {})\".format(\n                value, key, expected_type\n            )\n        )\n    return value\n\n\ndef _get_required(\n    d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None\n) -> T:\n    value = _get(d, expected_type, key, default)\n    if value is None:\n        raise DirectUrlValidationError(f\"{key} must have a value\")\n    return value\n\n\ndef _exactly_one_of(infos: Iterable[Optional[\"InfoType\"]]) -> \"InfoType\":\n    infos = [info for info in infos if info is not None]\n    if not infos:\n        raise DirectUrlValidationError(\n            \"missing one of archive_info, dir_info, vcs_info\"\n        )\n    if len(infos) > 1:\n        raise DirectUrlValidationError(\n            \"more than one of archive_info, dir_info, vcs_info\"\n        )\n    assert infos[0] is not None\n    return infos[0]\n\n\ndef _filter_none(**kwargs: Any) -> Dict[str, Any]:\n    \"\"\"Make dict excluding None values.\"\"\"\n    return {k: v for k, v in kwargs.items() if v is not None}\n\n\nclass VcsInfo:\n    name = \"vcs_info\"\n\n    def __init__(\n        self,\n        vcs: str,\n        commit_id: str,\n        requested_revision: Optional[str] = None,\n    ) -> None:\n        self.vcs = vcs\n        self.requested_revision = requested_revision\n        self.commit_id = commit_id\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"VcsInfo\"]:\n        if d is None:\n            return None\n        return cls(\n            vcs=_get_required(d, str, \"vcs\"),\n            commit_id=_get_required(d, str, \"commit_id\"),\n            requested_revision=_get(d, str, \"requested_revision\"),\n        )\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(\n            vcs=self.vcs,\n            requested_revision=self.requested_revision,\n            commit_id=self.commit_id,\n        )\n\n\nclass ArchiveInfo:\n    name = \"archive_info\"\n\n    def __init__(\n        self,\n        hash: Optional[str] = None,\n        hashes: Optional[Dict[str, str]] = None,\n    ) -> None:\n        if hash is not None:\n            # Auto-populate the hashes key to upgrade to the new format automatically.\n            # We don't back-populate the legacy hash key.\n            hash_name, hash_value = hash.split(\"=\", 1)\n            if hashes is None:\n                hashes = {hash_name: hash_value}\n            elif hash_name not in hash:\n                hashes = hashes.copy()\n                hashes[hash_name] = hash_value\n        self.hash = hash\n        self.hashes = hashes\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"ArchiveInfo\"]:\n        if d is None:\n            return None\n        return cls(hash=_get(d, str, \"hash\"), hashes=_get(d, dict, \"hashes\"))\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(hash=self.hash, hashes=self.hashes)\n\n\nclass DirInfo:\n    name = \"dir_info\"\n\n    def __init__(\n        self,\n        editable: bool = False,\n    ) -> None:\n        self.editable = editable\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"DirInfo\"]:\n        if d is None:\n            return None\n        return cls(editable=_get_required(d, bool, \"editable\", default=False))\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(editable=self.editable or None)\n\n\nInfoType = Union[ArchiveInfo, DirInfo, VcsInfo]\n\n\nclass DirectUrl:\n    def __init__(\n        self,\n        url: str,\n        info: InfoType,\n        subdirectory: Optional[str] = None,\n    ) -> None:\n        self.url = url\n        self.info = info\n        self.subdirectory = subdirectory\n\n    def _remove_auth_from_netloc(self, netloc: str) -> str:\n        if \"@\" not in netloc:\n            return netloc\n        user_pass, netloc_no_user_pass = netloc.split(\"@\", 1)\n        if (\n            isinstance(self.info, VcsInfo)\n            and self.info.vcs == \"git\"\n            and user_pass == \"git\"\n        ):\n            return netloc\n        if ENV_VAR_RE.match(user_pass):\n            return netloc\n        return netloc_no_user_pass\n\n    @property\n    def redacted_url(self) -> str:\n        \"\"\"url with user:password part removed unless it is formed with\n        environment variables as specified in PEP 610, or it is ``git``\n        in the case of a git URL.\n        \"\"\"\n        purl = urllib.parse.urlsplit(self.url)\n        netloc = self._remove_auth_from_netloc(purl.netloc)\n        surl = urllib.parse.urlunsplit(\n            (purl.scheme, netloc, purl.path, purl.query, purl.fragment)\n        )\n        return surl\n\n    def validate(self) -> None:\n        self.from_dict(self.to_dict())\n\n    @classmethod\n    def from_dict(cls, d: Dict[str, Any]) -> \"DirectUrl\":\n        return DirectUrl(\n            url=_get_required(d, str, \"url\"),\n            subdirectory=_get(d, str, \"subdirectory\"),\n            info=_exactly_one_of(\n                [\n                    ArchiveInfo._from_dict(_get(d, dict, \"archive_info\")),\n                    DirInfo._from_dict(_get(d, dict, \"dir_info\")),\n                    VcsInfo._from_dict(_get(d, dict, \"vcs_info\")),\n                ]\n            ),\n        )\n\n    def to_dict(self) -> Dict[str, Any]:\n        res = _filter_none(\n            url=self.redacted_url,\n            subdirectory=self.subdirectory,\n        )\n        res[self.info.name] = self.info._to_dict()\n        return res\n\n    @classmethod\n    def from_json(cls, s: str) -> \"DirectUrl\":\n        return cls.from_dict(json.loads(s))\n\n    def to_json(self) -> str:\n        return json.dumps(self.to_dict(), sort_keys=True)\n\n    def is_local_editable(self) -> bool:\n        return isinstance(self.info, DirInfo) and self.info.editable\n", "path": "src/pip/_internal/models/direct_url.py"}], "after_files": [{"content": "\"\"\" PEP 610 \"\"\"\nimport json\nimport re\nimport urllib.parse\nfrom typing import Any, Dict, Iterable, Optional, Type, TypeVar, Union\n\n__all__ = [\n    \"DirectUrl\",\n    \"DirectUrlValidationError\",\n    \"DirInfo\",\n    \"ArchiveInfo\",\n    \"VcsInfo\",\n]\n\nT = TypeVar(\"T\")\n\nDIRECT_URL_METADATA_NAME = \"direct_url.json\"\nENV_VAR_RE = re.compile(r\"^\\$\\{[A-Za-z0-9-_]+\\}(:\\$\\{[A-Za-z0-9-_]+\\})?$\")\n\n\nclass DirectUrlValidationError(Exception):\n    pass\n\n\ndef _get(\n    d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None\n) -> Optional[T]:\n    \"\"\"Get value from dictionary and verify expected type.\"\"\"\n    if key not in d:\n        return default\n    value = d[key]\n    if not isinstance(value, expected_type):\n        raise DirectUrlValidationError(\n            \"{!r} has unexpected type for {} (expected {})\".format(\n                value, key, expected_type\n            )\n        )\n    return value\n\n\ndef _get_required(\n    d: Dict[str, Any], expected_type: Type[T], key: str, default: Optional[T] = None\n) -> T:\n    value = _get(d, expected_type, key, default)\n    if value is None:\n        raise DirectUrlValidationError(f\"{key} must have a value\")\n    return value\n\n\ndef _exactly_one_of(infos: Iterable[Optional[\"InfoType\"]]) -> \"InfoType\":\n    infos = [info for info in infos if info is not None]\n    if not infos:\n        raise DirectUrlValidationError(\n            \"missing one of archive_info, dir_info, vcs_info\"\n        )\n    if len(infos) > 1:\n        raise DirectUrlValidationError(\n            \"more than one of archive_info, dir_info, vcs_info\"\n        )\n    assert infos[0] is not None\n    return infos[0]\n\n\ndef _filter_none(**kwargs: Any) -> Dict[str, Any]:\n    \"\"\"Make dict excluding None values.\"\"\"\n    return {k: v for k, v in kwargs.items() if v is not None}\n\n\nclass VcsInfo:\n    name = \"vcs_info\"\n\n    def __init__(\n        self,\n        vcs: str,\n        commit_id: str,\n        requested_revision: Optional[str] = None,\n    ) -> None:\n        self.vcs = vcs\n        self.requested_revision = requested_revision\n        self.commit_id = commit_id\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"VcsInfo\"]:\n        if d is None:\n            return None\n        return cls(\n            vcs=_get_required(d, str, \"vcs\"),\n            commit_id=_get_required(d, str, \"commit_id\"),\n            requested_revision=_get(d, str, \"requested_revision\"),\n        )\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(\n            vcs=self.vcs,\n            requested_revision=self.requested_revision,\n            commit_id=self.commit_id,\n        )\n\n\nclass ArchiveInfo:\n    name = \"archive_info\"\n\n    def __init__(\n        self,\n        hash: Optional[str] = None,\n        hashes: Optional[Dict[str, str]] = None,\n    ) -> None:\n        if hash is not None:\n            # Auto-populate the hashes key to upgrade to the new format automatically.\n            # We don't back-populate the legacy hash key.\n            try:\n                hash_name, hash_value = hash.split(\"=\", 1)\n            except ValueError:\n                raise DirectUrlValidationError(\n                    f\"invalid archive_info.hash format: {hash!r}\"\n                )\n            if hashes is None:\n                hashes = {hash_name: hash_value}\n            elif hash_name not in hash:\n                hashes = hashes.copy()\n                hashes[hash_name] = hash_value\n        self.hash = hash\n        self.hashes = hashes\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"ArchiveInfo\"]:\n        if d is None:\n            return None\n        return cls(hash=_get(d, str, \"hash\"), hashes=_get(d, dict, \"hashes\"))\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(hash=self.hash, hashes=self.hashes)\n\n\nclass DirInfo:\n    name = \"dir_info\"\n\n    def __init__(\n        self,\n        editable: bool = False,\n    ) -> None:\n        self.editable = editable\n\n    @classmethod\n    def _from_dict(cls, d: Optional[Dict[str, Any]]) -> Optional[\"DirInfo\"]:\n        if d is None:\n            return None\n        return cls(editable=_get_required(d, bool, \"editable\", default=False))\n\n    def _to_dict(self) -> Dict[str, Any]:\n        return _filter_none(editable=self.editable or None)\n\n\nInfoType = Union[ArchiveInfo, DirInfo, VcsInfo]\n\n\nclass DirectUrl:\n    def __init__(\n        self,\n        url: str,\n        info: InfoType,\n        subdirectory: Optional[str] = None,\n    ) -> None:\n        self.url = url\n        self.info = info\n        self.subdirectory = subdirectory\n\n    def _remove_auth_from_netloc(self, netloc: str) -> str:\n        if \"@\" not in netloc:\n            return netloc\n        user_pass, netloc_no_user_pass = netloc.split(\"@\", 1)\n        if (\n            isinstance(self.info, VcsInfo)\n            and self.info.vcs == \"git\"\n            and user_pass == \"git\"\n        ):\n            return netloc\n        if ENV_VAR_RE.match(user_pass):\n            return netloc\n        return netloc_no_user_pass\n\n    @property\n    def redacted_url(self) -> str:\n        \"\"\"url with user:password part removed unless it is formed with\n        environment variables as specified in PEP 610, or it is ``git``\n        in the case of a git URL.\n        \"\"\"\n        purl = urllib.parse.urlsplit(self.url)\n        netloc = self._remove_auth_from_netloc(purl.netloc)\n        surl = urllib.parse.urlunsplit(\n            (purl.scheme, netloc, purl.path, purl.query, purl.fragment)\n        )\n        return surl\n\n    def validate(self) -> None:\n        self.from_dict(self.to_dict())\n\n    @classmethod\n    def from_dict(cls, d: Dict[str, Any]) -> \"DirectUrl\":\n        return DirectUrl(\n            url=_get_required(d, str, \"url\"),\n            subdirectory=_get(d, str, \"subdirectory\"),\n            info=_exactly_one_of(\n                [\n                    ArchiveInfo._from_dict(_get(d, dict, \"archive_info\")),\n                    DirInfo._from_dict(_get(d, dict, \"dir_info\")),\n                    VcsInfo._from_dict(_get(d, dict, \"vcs_info\")),\n                ]\n            ),\n        )\n\n    def to_dict(self) -> Dict[str, Any]:\n        res = _filter_none(\n            url=self.redacted_url,\n            subdirectory=self.subdirectory,\n        )\n        res[self.info.name] = self.info._to_dict()\n        return res\n\n    @classmethod\n    def from_json(cls, s: str) -> \"DirectUrl\":\n        return cls.from_dict(json.loads(s))\n\n    def to_json(self) -> str:\n        return json.dumps(self.to_dict(), sort_keys=True)\n\n    def is_local_editable(self) -> bool:\n        return isinstance(self.info, DirInfo) and self.info.editable\n", "path": "src/pip/_internal/models/direct_url.py"}]}