pylock.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943
  1. from __future__ import annotations
  2. import dataclasses
  3. import logging
  4. import re
  5. from collections.abc import Mapping, Sequence
  6. from dataclasses import dataclass
  7. from datetime import datetime
  8. from typing import (
  9. TYPE_CHECKING,
  10. Any,
  11. Callable,
  12. Protocol,
  13. TypeVar,
  14. cast,
  15. )
  16. from urllib.parse import unquote, urlparse
  17. from .markers import (
  18. Environment,
  19. Marker,
  20. _pep440_python_full_version,
  21. default_environment,
  22. )
  23. from .specifiers import SpecifierSet
  24. from .tags import create_compatible_tags_selector, sys_tags
  25. from .utils import (
  26. NormalizedName,
  27. is_normalized_name,
  28. parse_sdist_filename,
  29. parse_wheel_filename,
  30. )
  31. from .version import Version
  32. if TYPE_CHECKING: # pragma: no cover
  33. from collections.abc import Collection, Iterator
  34. from pathlib import Path
  35. from typing_extensions import Self
  36. from .tags import Tag
  37. _logger = logging.getLogger(__name__)
  38. __all__ = [
  39. "Package",
  40. "PackageArchive",
  41. "PackageDirectory",
  42. "PackageSdist",
  43. "PackageVcs",
  44. "PackageWheel",
  45. "Pylock",
  46. "PylockSelectError",
  47. "PylockUnsupportedVersionError",
  48. "PylockValidationError",
  49. "is_valid_pylock_path",
  50. ]
  51. def __dir__() -> list[str]:
  52. return __all__
  53. _T = TypeVar("_T")
  54. _T2 = TypeVar("_T2")
  55. class _FromMappingProtocol(Protocol): # pragma: no cover
  56. @classmethod
  57. def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
  58. _FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
  59. _PYLOCK_FILE_NAME_RE = re.compile(r"^pylock\.([^.]+)\.toml$")
  60. def is_valid_pylock_path(path: Path) -> bool:
  61. """Check if the given path is a valid pylock file path."""
  62. return path.name == "pylock.toml" or bool(_PYLOCK_FILE_NAME_RE.match(path.name))
  63. def _toml_key(key: str) -> str:
  64. return key.replace("_", "-")
  65. def _toml_value(key: str, value: Any) -> Any: # noqa: ANN401
  66. if isinstance(value, (Version, Marker, SpecifierSet)):
  67. return str(value)
  68. if isinstance(value, Sequence) and key == "environments":
  69. return [str(v) for v in value]
  70. return value
  71. def _toml_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
  72. return {
  73. _toml_key(key): _toml_value(key, value)
  74. for key, value in data
  75. if value is not None
  76. }
  77. def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
  78. """Get a value from the dictionary and verify it's the expected type."""
  79. if (value := d.get(key)) is None:
  80. return None
  81. if not isinstance(value, expected_type) or (
  82. # Special case: bool is a subclass of int, but TOML distinguishes the two
  83. expected_type is int and isinstance(value, bool)
  84. ):
  85. raise PylockValidationError(
  86. f"Unexpected type {type(value).__name__} "
  87. f"(expected {expected_type.__name__})",
  88. context=key,
  89. )
  90. return value
  91. def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
  92. """Get a required value from the dictionary and verify it's the expected type."""
  93. if (value := _get(d, expected_type, key)) is None:
  94. raise _PylockRequiredKeyError(key)
  95. return value
  96. def _get_sequence(
  97. d: Mapping[str, Any], expected_item_type: type[_T], key: str
  98. ) -> Sequence[_T] | None:
  99. """Get a list value from the dictionary and verify it's the expected items type."""
  100. if (value := _get(d, Sequence, key)) is None: # type: ignore[type-abstract]
  101. return None
  102. if isinstance(value, (str, bytes)):
  103. # special case: str and bytes are Sequences, but we want to reject it
  104. raise PylockValidationError(
  105. f"Unexpected type {type(value).__name__} (expected Sequence)",
  106. context=key,
  107. )
  108. for i, item in enumerate(value):
  109. if not isinstance(item, expected_item_type):
  110. raise PylockValidationError(
  111. f"Unexpected type {type(item).__name__} "
  112. f"(expected {expected_item_type.__name__})",
  113. context=f"{key}[{i}]",
  114. )
  115. return value
  116. def _get_as(
  117. d: Mapping[str, Any],
  118. expected_type: type[_T],
  119. target_type: Callable[[_T], _T2],
  120. key: str,
  121. ) -> _T2 | None:
  122. """Get a value from the dictionary, verify it's the expected type,
  123. and convert to the target type.
  124. This assumes the target_type constructor accepts the value.
  125. """
  126. if (value := _get(d, expected_type, key)) is None:
  127. return None
  128. try:
  129. return target_type(value)
  130. except Exception as e:
  131. raise PylockValidationError(e, context=key) from e
  132. def _get_required_as(
  133. d: Mapping[str, Any],
  134. expected_type: type[_T],
  135. target_type: Callable[[_T], _T2],
  136. key: str,
  137. ) -> _T2:
  138. """Get a required value from the dict, verify it's the expected type,
  139. and convert to the target type."""
  140. if (value := _get_as(d, expected_type, target_type, key)) is None:
  141. raise _PylockRequiredKeyError(key)
  142. return value
  143. def _get_sequence_as(
  144. d: Mapping[str, Any],
  145. expected_item_type: type[_T],
  146. target_item_type: Callable[[_T], _T2],
  147. key: str,
  148. ) -> list[_T2] | None:
  149. """Get list value from dictionary and verify expected items type."""
  150. if (value := _get_sequence(d, expected_item_type, key)) is None:
  151. return None
  152. result = []
  153. try:
  154. for item in value:
  155. typed_item = target_item_type(item)
  156. result.append(typed_item)
  157. except Exception as e:
  158. raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
  159. return result
  160. def _get_object(
  161. d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
  162. ) -> _FromMappingProtocolT | None:
  163. """Get a dictionary value from the dictionary and convert it to a dataclass."""
  164. if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
  165. return None
  166. try:
  167. return target_type._from_dict(value)
  168. except Exception as e:
  169. raise PylockValidationError(e, context=key) from e
  170. def _get_sequence_of_objects(
  171. d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str
  172. ) -> list[_FromMappingProtocolT] | None:
  173. """Get a list value from the dictionary and convert its items to a dataclass."""
  174. if (value := _get_sequence(d, Mapping, key)) is None: # type: ignore[type-abstract]
  175. return None
  176. result: list[_FromMappingProtocolT] = []
  177. try:
  178. for item in value:
  179. typed_item = target_item_type._from_dict(item)
  180. result.append(typed_item)
  181. except Exception as e:
  182. raise PylockValidationError(e, context=f"{key}[{len(result)}]") from e
  183. return result
  184. def _get_required_sequence_of_objects(
  185. d: Mapping[str, Any], target_item_type: type[_FromMappingProtocolT], key: str
  186. ) -> Sequence[_FromMappingProtocolT]:
  187. """Get a required list value from the dictionary and convert its items to a
  188. dataclass."""
  189. if (result := _get_sequence_of_objects(d, target_item_type, key)) is None:
  190. raise _PylockRequiredKeyError(key)
  191. return result
  192. def _validate_normalized_name(name: str) -> NormalizedName:
  193. """Validate that a string is a NormalizedName."""
  194. if not is_normalized_name(name):
  195. raise PylockValidationError(f"Name {name!r} is not normalized")
  196. return NormalizedName(name)
  197. def _validate_path_url(path: str | None, url: str | None) -> None:
  198. if not path and not url:
  199. raise PylockValidationError("path or url must be provided")
  200. def _path_name(path: str | None) -> str | None:
  201. if not path:
  202. return None
  203. # If the path is relative it MAY use POSIX-style path separators explicitly
  204. # for portability
  205. if "/" in path:
  206. return path.rsplit("/", 1)[-1]
  207. elif "\\" in path:
  208. return path.rsplit("\\", 1)[-1]
  209. else:
  210. return path
  211. def _url_name(url: str | None) -> str | None:
  212. if not url:
  213. return None
  214. url_path = urlparse(url).path
  215. # The last path component is percent-encoded, so decode it to the file name
  216. return unquote(url_path.rsplit("/", 1)[-1])
  217. def _validate_hashes(hashes: Mapping[str, Any]) -> Mapping[str, Any]:
  218. if not hashes:
  219. raise PylockValidationError("At least one hash must be provided")
  220. if not all(isinstance(hash_val, str) for hash_val in hashes.values()):
  221. raise PylockValidationError("Hash values must be strings")
  222. return hashes
  223. class PylockValidationError(Exception):
  224. """Raised when when input data is not spec-compliant."""
  225. context: str | None = None
  226. message: str
  227. def __init__(
  228. self,
  229. cause: str | Exception,
  230. *,
  231. context: str | None = None,
  232. ) -> None:
  233. if isinstance(cause, PylockValidationError):
  234. if cause.context:
  235. self.context = (
  236. f"{context}.{cause.context}" if context else cause.context
  237. )
  238. else:
  239. self.context = context
  240. self.message = cause.message
  241. else:
  242. self.context = context
  243. self.message = str(cause)
  244. def __str__(self) -> str:
  245. if self.context:
  246. return f"{self.message} in {self.context!r}"
  247. return self.message
  248. class _PylockRequiredKeyError(PylockValidationError):
  249. def __init__(self, key: str) -> None:
  250. super().__init__("Missing required value", context=key)
  251. class PylockUnsupportedVersionError(PylockValidationError):
  252. """Raised when encountering an unsupported `lock_version`."""
  253. class PylockSelectError(Exception):
  254. """Base exception for errors raised by :meth:`Pylock.select`.
  255. .. versionadded:: 26.1
  256. """
  257. @dataclass(frozen=True, init=False)
  258. class PackageVcs:
  259. type: str
  260. url: str | None = None
  261. path: str | None = None
  262. requested_revision: str | None = None
  263. commit_id: str # type: ignore[misc]
  264. subdirectory: str | None = None
  265. def __init__(
  266. self,
  267. *,
  268. type: str,
  269. url: str | None = None,
  270. path: str | None = None,
  271. requested_revision: str | None = None,
  272. commit_id: str,
  273. subdirectory: str | None = None,
  274. ) -> None:
  275. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  276. object.__setattr__(self, "type", type)
  277. object.__setattr__(self, "url", url)
  278. object.__setattr__(self, "path", path)
  279. object.__setattr__(self, "requested_revision", requested_revision)
  280. object.__setattr__(self, "commit_id", commit_id)
  281. object.__setattr__(self, "subdirectory", subdirectory)
  282. @classmethod
  283. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  284. package_vcs = cls(
  285. type=_get_required(d, str, "type"),
  286. url=_get(d, str, "url"),
  287. path=_get(d, str, "path"),
  288. requested_revision=_get(d, str, "requested-revision"),
  289. commit_id=_get_required(d, str, "commit-id"),
  290. subdirectory=_get(d, str, "subdirectory"),
  291. )
  292. _validate_path_url(package_vcs.path, package_vcs.url)
  293. return package_vcs
  294. @dataclass(frozen=True, init=False)
  295. class PackageDirectory:
  296. path: str
  297. editable: bool | None = None
  298. subdirectory: str | None = None
  299. def __init__(
  300. self,
  301. *,
  302. path: str,
  303. editable: bool | None = None,
  304. subdirectory: str | None = None,
  305. ) -> None:
  306. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  307. object.__setattr__(self, "path", path)
  308. object.__setattr__(self, "editable", editable)
  309. object.__setattr__(self, "subdirectory", subdirectory)
  310. @classmethod
  311. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  312. return cls(
  313. path=_get_required(d, str, "path"),
  314. editable=_get(d, bool, "editable"),
  315. subdirectory=_get(d, str, "subdirectory"),
  316. )
  317. @dataclass(frozen=True, init=False)
  318. class PackageArchive:
  319. url: str | None = None
  320. path: str | None = None
  321. size: int | None = None
  322. upload_time: datetime | None = None
  323. hashes: Mapping[str, str] # type: ignore[misc]
  324. subdirectory: str | None = None
  325. def __init__(
  326. self,
  327. *,
  328. url: str | None = None,
  329. path: str | None = None,
  330. size: int | None = None,
  331. upload_time: datetime | None = None,
  332. hashes: Mapping[str, str],
  333. subdirectory: str | None = None,
  334. ) -> None:
  335. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  336. object.__setattr__(self, "url", url)
  337. object.__setattr__(self, "path", path)
  338. object.__setattr__(self, "size", size)
  339. object.__setattr__(self, "upload_time", upload_time)
  340. object.__setattr__(self, "hashes", hashes)
  341. object.__setattr__(self, "subdirectory", subdirectory)
  342. @classmethod
  343. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  344. package_archive = cls(
  345. url=_get(d, str, "url"),
  346. path=_get(d, str, "path"),
  347. size=_get(d, int, "size"),
  348. upload_time=_get(d, datetime, "upload-time"),
  349. hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
  350. subdirectory=_get(d, str, "subdirectory"),
  351. )
  352. _validate_path_url(package_archive.path, package_archive.url)
  353. return package_archive
  354. @dataclass(frozen=True, init=False)
  355. class PackageSdist:
  356. name: str | None = None
  357. upload_time: datetime | None = None
  358. url: str | None = None
  359. path: str | None = None
  360. size: int | None = None
  361. hashes: Mapping[str, str] # type: ignore[misc]
  362. def __init__(
  363. self,
  364. *,
  365. name: str | None = None,
  366. upload_time: datetime | None = None,
  367. url: str | None = None,
  368. path: str | None = None,
  369. size: int | None = None,
  370. hashes: Mapping[str, str],
  371. ) -> None:
  372. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  373. object.__setattr__(self, "name", name)
  374. object.__setattr__(self, "upload_time", upload_time)
  375. object.__setattr__(self, "url", url)
  376. object.__setattr__(self, "path", path)
  377. object.__setattr__(self, "size", size)
  378. object.__setattr__(self, "hashes", hashes)
  379. @classmethod
  380. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  381. package_sdist = cls(
  382. name=_get(d, str, "name"),
  383. upload_time=_get(d, datetime, "upload-time"),
  384. url=_get(d, str, "url"),
  385. path=_get(d, str, "path"),
  386. size=_get(d, int, "size"),
  387. hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
  388. )
  389. _validate_path_url(package_sdist.path, package_sdist.url)
  390. return package_sdist
  391. @property
  392. def filename(self) -> str:
  393. """Get the filename of the sdist.
  394. .. versionadded:: 26.1
  395. """
  396. filename = self.name or _path_name(self.path) or _url_name(self.url)
  397. if not filename:
  398. raise PylockValidationError("Cannot determine sdist filename")
  399. return filename
  400. @dataclass(frozen=True, init=False)
  401. class PackageWheel:
  402. name: str | None = None
  403. upload_time: datetime | None = None
  404. url: str | None = None
  405. path: str | None = None
  406. size: int | None = None
  407. hashes: Mapping[str, str] # type: ignore[misc]
  408. def __init__(
  409. self,
  410. *,
  411. name: str | None = None,
  412. upload_time: datetime | None = None,
  413. url: str | None = None,
  414. path: str | None = None,
  415. size: int | None = None,
  416. hashes: Mapping[str, str],
  417. ) -> None:
  418. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  419. object.__setattr__(self, "name", name)
  420. object.__setattr__(self, "upload_time", upload_time)
  421. object.__setattr__(self, "url", url)
  422. object.__setattr__(self, "path", path)
  423. object.__setattr__(self, "size", size)
  424. object.__setattr__(self, "hashes", hashes)
  425. @classmethod
  426. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  427. package_wheel = cls(
  428. name=_get(d, str, "name"),
  429. upload_time=_get(d, datetime, "upload-time"),
  430. url=_get(d, str, "url"),
  431. path=_get(d, str, "path"),
  432. size=_get(d, int, "size"),
  433. hashes=_get_required_as(d, Mapping, _validate_hashes, "hashes"), # type: ignore[type-abstract]
  434. )
  435. _validate_path_url(package_wheel.path, package_wheel.url)
  436. return package_wheel
  437. @property
  438. def filename(self) -> str:
  439. """Get the filename of the wheel."""
  440. filename = self.name or _path_name(self.path) or _url_name(self.url)
  441. if not filename:
  442. raise PylockValidationError("Cannot determine wheel filename")
  443. return filename
  444. @dataclass(frozen=True, init=False)
  445. class Package:
  446. name: NormalizedName
  447. version: Version | None = None
  448. marker: Marker | None = None
  449. requires_python: SpecifierSet | None = None
  450. dependencies: Sequence[Mapping[str, Any]] | None = None
  451. vcs: PackageVcs | None = None
  452. directory: PackageDirectory | None = None
  453. archive: PackageArchive | None = None
  454. index: str | None = None
  455. sdist: PackageSdist | None = None
  456. wheels: Sequence[PackageWheel] | None = None
  457. attestation_identities: Sequence[Mapping[str, Any]] | None = None
  458. tool: Mapping[str, Any] | None = None
  459. def __init__(
  460. self,
  461. *,
  462. name: NormalizedName,
  463. version: Version | None = None,
  464. marker: Marker | None = None,
  465. requires_python: SpecifierSet | None = None,
  466. dependencies: Sequence[Mapping[str, Any]] | None = None,
  467. vcs: PackageVcs | None = None,
  468. directory: PackageDirectory | None = None,
  469. archive: PackageArchive | None = None,
  470. index: str | None = None,
  471. sdist: PackageSdist | None = None,
  472. wheels: Sequence[PackageWheel] | None = None,
  473. attestation_identities: Sequence[Mapping[str, Any]] | None = None,
  474. tool: Mapping[str, Any] | None = None,
  475. ) -> None:
  476. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  477. object.__setattr__(self, "name", name)
  478. object.__setattr__(self, "version", version)
  479. object.__setattr__(self, "marker", marker)
  480. object.__setattr__(self, "requires_python", requires_python)
  481. object.__setattr__(self, "dependencies", dependencies)
  482. object.__setattr__(self, "vcs", vcs)
  483. object.__setattr__(self, "directory", directory)
  484. object.__setattr__(self, "archive", archive)
  485. object.__setattr__(self, "index", index)
  486. object.__setattr__(self, "sdist", sdist)
  487. object.__setattr__(self, "wheels", wheels)
  488. object.__setattr__(self, "attestation_identities", attestation_identities)
  489. object.__setattr__(self, "tool", tool)
  490. @classmethod
  491. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  492. package = cls(
  493. name=_get_required_as(d, str, _validate_normalized_name, "name"),
  494. version=_get_as(d, str, Version, "version"),
  495. requires_python=_get_as(d, str, SpecifierSet, "requires-python"),
  496. dependencies=_get_sequence(d, Mapping, "dependencies"), # type: ignore[type-abstract]
  497. marker=_get_as(d, str, Marker, "marker"),
  498. vcs=_get_object(d, PackageVcs, "vcs"),
  499. directory=_get_object(d, PackageDirectory, "directory"),
  500. archive=_get_object(d, PackageArchive, "archive"),
  501. index=_get(d, str, "index"),
  502. sdist=_get_object(d, PackageSdist, "sdist"),
  503. wheels=_get_sequence_of_objects(d, PackageWheel, "wheels"),
  504. attestation_identities=_get_sequence(d, Mapping, "attestation-identities"), # type: ignore[type-abstract]
  505. tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract]
  506. )
  507. distributions = bool(package.sdist) + len(package.wheels or [])
  508. direct_urls = (
  509. bool(package.vcs) + bool(package.directory) + bool(package.archive)
  510. )
  511. if distributions > 0 and direct_urls > 0:
  512. raise PylockValidationError(
  513. "None of vcs, directory, archive must be set if sdist or wheels are set"
  514. )
  515. if distributions == 0 and direct_urls != 1:
  516. raise PylockValidationError(
  517. "Exactly one of vcs, directory, archive must be set "
  518. "if sdist and wheels are not set"
  519. )
  520. for i, wheel in enumerate(package.wheels or []):
  521. try:
  522. (name, version, _, _) = parse_wheel_filename(wheel.filename)
  523. except Exception as e:
  524. raise PylockValidationError(
  525. f"Invalid wheel filename {wheel.filename!r}",
  526. context=f"wheels[{i}]",
  527. ) from e
  528. if name != package.name:
  529. raise PylockValidationError(
  530. f"Name in {wheel.filename!r} is not consistent with "
  531. f"package name {package.name!r}",
  532. context=f"wheels[{i}]",
  533. )
  534. if package.version and version != package.version:
  535. raise PylockValidationError(
  536. f"Version in {wheel.filename!r} is not consistent with "
  537. f"package version {str(package.version)!r}",
  538. context=f"wheels[{i}]",
  539. )
  540. if package.sdist:
  541. try:
  542. name, version = parse_sdist_filename(package.sdist.filename)
  543. except Exception as e:
  544. raise PylockValidationError(
  545. f"Invalid sdist filename {package.sdist.filename!r}",
  546. context="sdist",
  547. ) from e
  548. if name != package.name:
  549. raise PylockValidationError(
  550. f"Name in {package.sdist.filename!r} is not consistent with "
  551. f"package name {package.name!r}",
  552. context="sdist",
  553. )
  554. if package.version and version != package.version:
  555. raise PylockValidationError(
  556. f"Version in {package.sdist.filename!r} is not consistent with "
  557. f"package version {str(package.version)!r}",
  558. context="sdist",
  559. )
  560. try:
  561. for i, attestation_identity in enumerate( # noqa: B007
  562. package.attestation_identities or []
  563. ):
  564. _get_required(attestation_identity, str, "kind")
  565. except Exception as e:
  566. raise PylockValidationError(
  567. e, context=f"attestation-identities[{i}]"
  568. ) from e
  569. return package
  570. @property
  571. def is_direct(self) -> bool:
  572. return not (self.sdist or self.wheels)
  573. @dataclass(frozen=True, init=False)
  574. class Pylock:
  575. """A class representing a pylock file."""
  576. lock_version: Version
  577. environments: Sequence[Marker] | None = None
  578. requires_python: SpecifierSet | None = None
  579. extras: Sequence[NormalizedName] | None = None
  580. dependency_groups: Sequence[str] | None = None
  581. default_groups: Sequence[str] | None = None
  582. created_by: str # type: ignore[misc]
  583. packages: Sequence[Package] # type: ignore[misc]
  584. tool: Mapping[str, Any] | None = None
  585. def __init__(
  586. self,
  587. *,
  588. lock_version: Version,
  589. environments: Sequence[Marker] | None = None,
  590. requires_python: SpecifierSet | None = None,
  591. extras: Sequence[NormalizedName] | None = None,
  592. dependency_groups: Sequence[str] | None = None,
  593. default_groups: Sequence[str] | None = None,
  594. created_by: str,
  595. packages: Sequence[Package],
  596. tool: Mapping[str, Any] | None = None,
  597. ) -> None:
  598. # In Python 3.10+ make dataclass kw_only=True and remove __init__
  599. object.__setattr__(self, "lock_version", lock_version)
  600. object.__setattr__(self, "environments", environments)
  601. object.__setattr__(self, "requires_python", requires_python)
  602. object.__setattr__(self, "extras", extras)
  603. object.__setattr__(self, "dependency_groups", dependency_groups)
  604. object.__setattr__(self, "default_groups", default_groups)
  605. object.__setattr__(self, "created_by", created_by)
  606. object.__setattr__(self, "packages", packages)
  607. object.__setattr__(self, "tool", tool)
  608. @classmethod
  609. def _from_dict(cls, d: Mapping[str, Any]) -> Self:
  610. pylock = cls(
  611. lock_version=_get_required_as(d, str, Version, "lock-version"),
  612. environments=_get_sequence_as(d, str, Marker, "environments"),
  613. extras=_get_sequence_as(d, str, _validate_normalized_name, "extras"),
  614. dependency_groups=_get_sequence(d, str, "dependency-groups"),
  615. default_groups=_get_sequence(d, str, "default-groups"),
  616. created_by=_get_required(d, str, "created-by"),
  617. requires_python=_get_as(d, str, SpecifierSet, "requires-python"),
  618. packages=_get_required_sequence_of_objects(d, Package, "packages"),
  619. tool=_get(d, Mapping, "tool"), # type: ignore[type-abstract]
  620. )
  621. if not Version("1") <= pylock.lock_version < Version("2"):
  622. raise PylockUnsupportedVersionError(
  623. f"pylock version {pylock.lock_version} is not supported"
  624. )
  625. if pylock.lock_version > Version("1.0"):
  626. _logger.warning(
  627. "pylock minor version %s is not supported", pylock.lock_version
  628. )
  629. return pylock
  630. @classmethod
  631. def from_dict(cls, d: Mapping[str, Any], /) -> Self:
  632. """Create and validate a Pylock instance from a TOML dictionary.
  633. Raises :class:`PylockValidationError` if the input data is not
  634. spec-compliant.
  635. """
  636. return cls._from_dict(d)
  637. def to_dict(self) -> Mapping[str, Any]:
  638. """Convert the Pylock instance to a TOML dictionary."""
  639. return dataclasses.asdict(self, dict_factory=_toml_dict_factory)
  640. def validate(self) -> None:
  641. """Validate the Pylock instance against the specification.
  642. Raises :class:`PylockValidationError` otherwise."""
  643. self.from_dict(self.to_dict())
  644. def select(
  645. self,
  646. *,
  647. environment: Environment | None = None,
  648. tags: Sequence[Tag] | None = None,
  649. extras: Collection[str] | None = None,
  650. dependency_groups: Collection[str] | None = None,
  651. prefer_sdist_predicate: Callable[[NormalizedName], bool] | None = None,
  652. ) -> Iterator[
  653. tuple[
  654. Package,
  655. PackageVcs
  656. | PackageDirectory
  657. | PackageArchive
  658. | PackageWheel
  659. | PackageSdist,
  660. ]
  661. ]:
  662. """Select what to install from the lock file.
  663. The *environment* and *tags* parameters represent the environment being
  664. selected for. If unspecified, ``packaging.markers.default_environment()`` and
  665. ``packaging.tags.sys_tags()`` are used.
  666. The *extras* parameter represents the extras to install.
  667. The *dependency_groups* parameter represents the groups to install. If
  668. unspecified, the default groups are used.
  669. The *prefer_sdist_predicate* parameter is called for packages with a source
  670. distribution. If it returns ``True``, the source distribution is selected
  671. before attempting wheel compatibility. If no source distribution is
  672. available, wheel selection proceeds as usual without calling the predicate.
  673. This method must be used on valid Pylock instances (i.e. one obtained
  674. from :meth:`Pylock.from_dict` or if constructed manually, after calling
  675. :meth:`Pylock.validate`).
  676. .. versionadded:: 26.1
  677. .. versionchanged:: 26.3
  678. Added the *prefer_sdist_predicate* parameter.
  679. """
  680. compatible_tags_selector = create_compatible_tags_selector(
  681. tags if tags is not None else sys_tags()
  682. )
  683. # #. Gather the extras and dependency groups to install and set ``extras`` and
  684. # ``dependency_groups`` for marker evaluation, respectively.
  685. #
  686. # #. ``extras`` SHOULD be set to the empty set by default.
  687. # #. ``dependency_groups`` SHOULD be the set created from
  688. # :ref:`pylock-default-groups` by default.
  689. env = cast(
  690. "dict[str, str | frozenset[str]]",
  691. dict(
  692. environment or {}, # Marker.evaluate will fill-up
  693. extras=frozenset(extras or []),
  694. dependency_groups=frozenset(
  695. (self.default_groups or [])
  696. if dependency_groups is None # to allow selecting no group
  697. else dependency_groups
  698. ),
  699. ),
  700. )
  701. env_python_full_version = _pep440_python_full_version(
  702. environment["python_full_version"]
  703. if environment
  704. else default_environment()["python_full_version"]
  705. )
  706. # #. Check if the metadata version specified by :ref:`pylock-lock-version` is
  707. # supported; an error or warning MUST be raised as appropriate.
  708. # Covered by lock.validate() which is a precondition for this method.
  709. # #. If :ref:`pylock-requires-python` is specified, check that the environment
  710. # being installed for meets the requirement; an error MUST be raised if it is
  711. # not met.
  712. if self.requires_python and not self.requires_python.contains(
  713. env_python_full_version,
  714. ):
  715. raise PylockSelectError(
  716. f"python_full_version {env_python_full_version!r} "
  717. f"in provided environment does not satisfy the Python version "
  718. f"requirement {str(self.requires_python)!r}"
  719. )
  720. # #. If :ref:`pylock-environments` is specified, check that at least one of the
  721. # environment marker expressions is satisfied; an error MUST be raised if no
  722. # expression is satisfied.
  723. if self.environments:
  724. for env_marker in self.environments:
  725. if env_marker.evaluate(
  726. cast("dict[str, str]", environment or {}), context="requirement"
  727. ):
  728. break
  729. else:
  730. raise PylockSelectError(
  731. "Provided environment does not satisfy any of the "
  732. "environments specified in the lock file"
  733. )
  734. # #. For each package listed in :ref:`pylock-packages`:
  735. selected_packages_by_name: dict[str, tuple[int, Package]] = {}
  736. for package_index, package in enumerate(self.packages):
  737. # #. If :ref:`pylock-packages-marker` is specified, check if it is
  738. # satisfied;if it isn't, skip to the next package.
  739. if package.marker and not package.marker.evaluate(env, context="lock_file"):
  740. continue
  741. # #. If :ref:`pylock-packages-requires-python` is specified, check if it is
  742. # satisfied; an error MUST be raised if it isn't.
  743. if package.requires_python and not package.requires_python.contains(
  744. env_python_full_version,
  745. ):
  746. raise PylockSelectError(
  747. f"python_full_version {env_python_full_version!r} "
  748. f"in provided environment does not satisfy the Python version "
  749. f"requirement {str(package.requires_python)!r} for package "
  750. f"{package.name!r} at packages[{package_index}]"
  751. )
  752. # #. Check that no other conflicting instance of the package has been slated
  753. # to be installed; an error about the ambiguity MUST be raised otherwise.
  754. if package.name in selected_packages_by_name:
  755. raise PylockSelectError(
  756. f"Multiple packages with the name {package.name!r} are "
  757. f"selected at packages[{package_index}] and "
  758. f"packages[{selected_packages_by_name[package.name][0]}]"
  759. )
  760. # #. Check that the source of the package is specified appropriately (i.e.
  761. # there are no conflicting sources in the package entry);
  762. # an error MUST be raised if any issues are found.
  763. # Covered by lock.validate() which is a precondition for this method.
  764. # #. Add the package to the set of packages to install.
  765. selected_packages_by_name[package.name] = (package_index, package)
  766. # #. For each package to be installed:
  767. for package_index, package in selected_packages_by_name.values():
  768. # - If :ref:`pylock-packages-vcs` is set:
  769. if package.vcs is not None:
  770. yield package, package.vcs
  771. # - Else if :ref:`pylock-packages-directory` is set:
  772. elif package.directory is not None:
  773. yield package, package.directory
  774. # - Else if :ref:`pylock-packages-archive` is set:
  775. elif package.archive is not None:
  776. yield package, package.archive
  777. # - Else if source preference selects an available
  778. # :ref:`pylock-packages-sdist`:
  779. elif (
  780. package.sdist is not None
  781. and prefer_sdist_predicate is not None
  782. and prefer_sdist_predicate(package.name)
  783. ):
  784. yield package, package.sdist
  785. # - Else if there are entries for :ref:`pylock-packages-wheels`:
  786. elif package.wheels:
  787. # #. Look for the appropriate wheel file based on
  788. # :ref:`pylock-packages-wheels-name`; if one is not found then move
  789. # on to :ref:`pylock-packages-sdist` or an error MUST be raised about
  790. # a lack of source for the project.
  791. best_wheel = next(
  792. compatible_tags_selector(
  793. (wheel, parse_wheel_filename(wheel.filename)[-1])
  794. for wheel in package.wheels
  795. ),
  796. None,
  797. )
  798. if best_wheel:
  799. yield package, best_wheel
  800. elif package.sdist is not None:
  801. yield package, package.sdist
  802. else:
  803. raise PylockSelectError(
  804. f"No wheel found matching the provided tags "
  805. f"for package {package.name!r} "
  806. f"at packages[{package_index}], "
  807. f"and no sdist available as a fallback"
  808. )
  809. # - Else if no :ref:`pylock-packages-wheels` file is found or
  810. # :ref:`pylock-packages-sdist` is solely set:
  811. elif package.sdist is not None:
  812. yield package, package.sdist
  813. else:
  814. # Covered by lock.validate() which is a precondition for this method.
  815. raise NotImplementedError # pragma: no cover