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