pylock.py 33 KB

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