direct_url.py 11 KB

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