1from __future__ import annotations
2
3import dataclasses
4import re
5import urllib.parse
6from collections.abc import Mapping
7from typing import TYPE_CHECKING, Any, Protocol, TypeVar
8
9if TYPE_CHECKING: # pragma: no cover
10 import sys
11 from collections.abc import Collection
12 from urllib.parse import SplitResult
13
14 if sys.version_info >= (3, 11):
15 from typing import Self
16 else:
17 from typing_extensions import Self
18
19__all__ = [
20 "ArchiveInfo",
21 "DirInfo",
22 "DirectUrl",
23 "DirectUrlValidationError",
24 "VcsInfo",
25]
26
27
28def __dir__() -> list[str]:
29 return __all__
30
31
32_T = TypeVar("_T")
33
34
35class _FromMappingProtocol(Protocol): # pragma: no cover
36 @classmethod
37 def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
38
39
40_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
41
42
43def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
44 return {key: value for key, value in data if value is not None}
45
46
47def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
48 """Get a value from the dictionary and verify it's the expected type."""
49 if (value := d.get(key)) is None:
50 return None
51 if not isinstance(value, expected_type):
52 raise DirectUrlValidationError(
53 f"Unexpected type {type(value).__name__} "
54 f"(expected {expected_type.__name__})",
55 context=key,
56 )
57 return value
58
59
60def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
61 """Get a required value from the dictionary and verify it's the expected type."""
62 if (value := _get(d, expected_type, key)) is None:
63 raise _DirectUrlRequiredKeyError(key)
64 return value
65
66
67def _get_object(
68 d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
69) -> _FromMappingProtocolT | None:
70 """Get a dictionary value from the dictionary and convert it to a dataclass."""
71 if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
72 return None
73 try:
74 return target_type._from_dict(value)
75 except Exception as e:
76 raise DirectUrlValidationError(e, context=key) from e
77
78
79_PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
80 r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$"
81)
82
83
84def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str:
85 if "@" not in netloc:
86 return netloc
87 user_pass, netloc_no_user_pass = netloc.rsplit("@", 1)
88 if user_pass in safe_user_passwords:
89 return netloc
90 if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
91 return netloc
92 return netloc_no_user_pass
93
94
95def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
96 """url with user:password part removed unless it is formed with
97 environment variables as specified in PEP 610, or it is a safe user:password
98 such as `git`.
99 """
100 parsed_url = urllib.parse.urlsplit(url)
101 netloc = _strip_auth_from_netloc(parsed_url.netloc, safe_user_passwords)
102 return urllib.parse.urlunsplit(
103 (
104 parsed_url.scheme,
105 netloc,
106 parsed_url.path,
107 parsed_url.query,
108 parsed_url.fragment,
109 )
110 )
111
112
113def _file_url_has_absolute_path(parsed_url: SplitResult) -> bool:
114 return parsed_url.path.startswith("/")
115
116
117class DirectUrlValidationError(Exception):
118 """Raised when when input data is not spec-compliant.
119
120 .. versionadded:: 26.1
121 """
122
123 context: str | None = None
124 message: str
125
126 def __init__(
127 self,
128 cause: str | Exception,
129 *,
130 context: str | None = None,
131 ) -> None:
132 if isinstance(cause, DirectUrlValidationError):
133 if cause.context:
134 self.context = (
135 f"{context}.{cause.context}" if context else cause.context
136 )
137 else:
138 self.context = context # pragma: no cover
139 self.message = cause.message
140 else:
141 self.context = context
142 self.message = str(cause)
143
144 def __str__(self) -> str:
145 if self.context:
146 return f"{self.message} in {self.context!r}"
147 return self.message
148
149
150class _DirectUrlRequiredKeyError(DirectUrlValidationError):
151 def __init__(self, key: str) -> None:
152 super().__init__("Missing required value", context=key)
153
154
155@dataclasses.dataclass(frozen=True, init=False)
156class VcsInfo:
157 """The version control information of a :class:`DirectUrl`."""
158
159 vcs: str
160 commit_id: str
161 requested_revision: str | None = None
162
163 def __init__(
164 self,
165 *,
166 vcs: str,
167 commit_id: str,
168 requested_revision: str | None = None,
169 ) -> None:
170 object.__setattr__(self, "vcs", vcs)
171 object.__setattr__(self, "commit_id", commit_id)
172 object.__setattr__(self, "requested_revision", requested_revision)
173
174 @classmethod
175 def _from_dict(cls, d: Mapping[str, Any]) -> Self:
176 # We can't validate vcs value because is not closed.
177 return cls(
178 vcs=_get_required(d, str, "vcs"),
179 requested_revision=_get(d, str, "requested_revision"),
180 commit_id=_get_required(d, str, "commit_id"),
181 )
182
183
184@dataclasses.dataclass(frozen=True, init=False)
185class ArchiveInfo:
186 """The archive information of a :class:`DirectUrl`."""
187
188 hashes: Mapping[str, str] | None = None
189
190 def __init__(
191 self,
192 *,
193 hashes: Mapping[str, str] | None = None,
194 ) -> None:
195 object.__setattr__(self, "hashes", hashes)
196
197 @classmethod
198 def _from_dict(cls, d: Mapping[str, Any]) -> Self:
199 hashes = _get(d, Mapping, "hashes") # type: ignore[type-abstract]
200 if hashes is not None and not all(isinstance(h, str) for h in hashes.values()):
201 raise DirectUrlValidationError(
202 "Hash values must be strings", context="hashes"
203 )
204 legacy_hash = _get(d, str, "hash")
205 if legacy_hash is not None:
206 if "=" not in legacy_hash:
207 raise DirectUrlValidationError(
208 "Invalid hash format (expected '<algorithm>=<hash>')",
209 context="hash",
210 )
211 hash_algorithm, hash_value = legacy_hash.split("=", 1)
212 if hashes is None:
213 # if `hashes` are not present, we can derive it from the legacy `hash`
214 hashes = {hash_algorithm: hash_value}
215 else:
216 # if `hashes` are present, the legacy `hash` must match one of them
217 if hash_algorithm not in hashes:
218 raise DirectUrlValidationError(
219 f"Algorithm {hash_algorithm!r} used in hash field "
220 f"is not present in hashes field",
221 context="hashes",
222 )
223 if hashes[hash_algorithm] != hash_value:
224 raise DirectUrlValidationError(
225 f"Algorithm {hash_algorithm!r} used in hash field "
226 f"has different value in hashes field",
227 context="hash",
228 )
229 return cls(hashes=hashes)
230
231
232@dataclasses.dataclass(frozen=True, init=False)
233class DirInfo:
234 """The local directory information of a :class:`DirectUrl`."""
235
236 editable: bool | None = None
237
238 def __init__(
239 self,
240 *,
241 editable: bool | None = None,
242 ) -> None:
243 object.__setattr__(self, "editable", editable)
244
245 @classmethod
246 def _from_dict(cls, d: Mapping[str, Any]) -> Self:
247 return cls(
248 editable=_get(d, bool, "editable"),
249 )
250
251
252@dataclasses.dataclass(frozen=True, init=False)
253class DirectUrl:
254 """A class representing a direct URL.
255
256 .. versionadded:: 26.1
257 """
258
259 url: str
260 archive_info: ArchiveInfo | None = None
261 vcs_info: VcsInfo | None = None
262 dir_info: DirInfo | None = None
263 subdirectory: str | None = None # XXX Path or str?
264
265 def __init__(
266 self,
267 *,
268 url: str,
269 archive_info: ArchiveInfo | None = None,
270 vcs_info: VcsInfo | None = None,
271 dir_info: DirInfo | None = None,
272 subdirectory: str | None = None,
273 ) -> None:
274 object.__setattr__(self, "url", url)
275 object.__setattr__(self, "archive_info", archive_info)
276 object.__setattr__(self, "vcs_info", vcs_info)
277 object.__setattr__(self, "dir_info", dir_info)
278 object.__setattr__(self, "subdirectory", subdirectory)
279
280 @classmethod
281 def _from_dict(cls, d: Mapping[str, Any]) -> Self:
282 direct_url = cls(
283 url=_get_required(d, str, "url"),
284 archive_info=_get_object(d, ArchiveInfo, "archive_info"),
285 vcs_info=_get_object(d, VcsInfo, "vcs_info"),
286 dir_info=_get_object(d, DirInfo, "dir_info"),
287 subdirectory=_get(d, str, "subdirectory"),
288 )
289 if (
290 bool(direct_url.vcs_info)
291 + bool(direct_url.archive_info)
292 + bool(direct_url.dir_info)
293 ) != 1:
294 raise DirectUrlValidationError(
295 "Exactly one of vcs_info, archive_info, dir_info must be present"
296 )
297 if direct_url.dir_info is not None:
298 parsed_url = urllib.parse.urlsplit(direct_url.url)
299 if parsed_url.scheme != "file":
300 raise DirectUrlValidationError(
301 "URL scheme must be file:// when dir_info is present",
302 context="url",
303 )
304 if not _file_url_has_absolute_path(parsed_url):
305 raise DirectUrlValidationError(
306 "File URL must be absolute when dir_info is present",
307 context="url",
308 )
309 # XXX subdirectory must be relative, can we, should we validate that here?
310 return direct_url
311
312 @classmethod
313 def from_dict(cls, d: Mapping[str, Any], /) -> Self:
314 """Create and validate a DirectUrl instance from a JSON dictionary."""
315 return cls._from_dict(d)
316
317 def to_dict(
318 self,
319 *,
320 generate_legacy_hash: bool = False,
321 strip_user_password: bool = True,
322 safe_user_passwords: Collection[str] = ("git",),
323 ) -> Mapping[str, Any]:
324 """Convert the DirectUrl instance to a JSON dictionary.
325
326 :param generate_legacy_hash: If True, include a legacy `hash` field in
327 `archive_info` for backward compatibility with tools that don't
328 support the `hashes` field.
329 :param strip_user_password: If True, strip user:password from the URL
330 unless it is formed with environment variables as specified in PEP
331 610, or it is a safe user:password such as `git`.
332 :param safe_user_passwords: A collection of user:password strings that
333 should not be stripped from the URL even if `strip_user_password` is
334 True.
335 """
336 res = dataclasses.asdict(self, dict_factory=_json_dict_factory)
337 if generate_legacy_hash and self.archive_info and self.archive_info.hashes:
338 hash_algorithm, hash_value = next(iter(self.archive_info.hashes.items()))
339 res["archive_info"]["hash"] = f"{hash_algorithm}={hash_value}"
340 if strip_user_password:
341 res["url"] = _strip_url(self.url, safe_user_passwords)
342 return res
343
344 def validate(self) -> None:
345 """Validate the DirectUrl instance against the specification.
346
347 Raises :class:`DirectUrlValidationError` if invalid.
348 """
349 self.from_dict(self.to_dict())