direct_url.py 11 KB

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