nab-python 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. nab_python/__init__.py +1 -0
  2. nab_python/_build/__init__.py +1 -0
  3. nab_python/_build/env.py +364 -0
  4. nab_python/_build/errors.py +17 -0
  5. nab_python/_build/runner.py +254 -0
  6. nab_python/_lockfile/__init__.py +1 -0
  7. nab_python/_lockfile/builder.py +339 -0
  8. nab_python/_lockfile/disjointness.py +207 -0
  9. nab_python/_lockfile/pylock.py +323 -0
  10. nab_python/_lockfile/requirements.py +121 -0
  11. nab_python/_packaging_provider.py +98 -0
  12. nab_python/_provider/__init__.py +1 -0
  13. nab_python/_provider/build_remote.py +95 -0
  14. nab_python/_provider/extras.py +231 -0
  15. nab_python/_provider/listing.py +442 -0
  16. nab_python/_provider/lookahead.py +156 -0
  17. nab_python/_provider/metadata_resolver.py +450 -0
  18. nab_python/_provider/priority.py +174 -0
  19. nab_python/_provider/sources.py +215 -0
  20. nab_python/_testing/__init__.py +1 -0
  21. nab_python/_testing/coordinator_fake.py +240 -0
  22. nab_python/_vcs_admission.py +209 -0
  23. nab_python/_vendor/__init__.py +6 -0
  24. nab_python/_vendor/packaging/LICENSE +3 -0
  25. nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
  26. nab_python/_vendor/packaging/LICENSE.BSD +23 -0
  27. nab_python/_vendor/packaging/PROVENANCE.md +73 -0
  28. nab_python/_vendor/packaging/__init__.py +15 -0
  29. nab_python/_vendor/packaging/_elffile.py +108 -0
  30. nab_python/_vendor/packaging/_manylinux.py +265 -0
  31. nab_python/_vendor/packaging/_musllinux.py +88 -0
  32. nab_python/_vendor/packaging/_parser.py +394 -0
  33. nab_python/_vendor/packaging/_structures.py +33 -0
  34. nab_python/_vendor/packaging/_tokenizer.py +196 -0
  35. nab_python/_vendor/packaging/dependency_groups.py +302 -0
  36. nab_python/_vendor/packaging/direct_url.py +325 -0
  37. nab_python/_vendor/packaging/errors.py +94 -0
  38. nab_python/_vendor/packaging/licenses/__init__.py +186 -0
  39. nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
  40. nab_python/_vendor/packaging/markers.py +506 -0
  41. nab_python/_vendor/packaging/metadata.py +964 -0
  42. nab_python/_vendor/packaging/py.typed +0 -0
  43. nab_python/_vendor/packaging/pylock.py +910 -0
  44. nab_python/_vendor/packaging/ranges.py +1803 -0
  45. nab_python/_vendor/packaging/requirements.py +132 -0
  46. nab_python/_vendor/packaging/specifiers.py +1141 -0
  47. nab_python/_vendor/packaging/tags.py +929 -0
  48. nab_python/_vendor/packaging/utils.py +296 -0
  49. nab_python/_vendor/packaging/version.py +1230 -0
  50. nab_python/build_backend.py +184 -0
  51. nab_python/config.py +805 -0
  52. nab_python/download.py +170 -0
  53. nab_python/fetch.py +827 -0
  54. nab_python/lockfile.py +238 -0
  55. nab_python/metadata.py +145 -0
  56. nab_python/provider.py +1235 -0
  57. nab_python/py.typed +0 -0
  58. nab_python/requirements_file.py +180 -0
  59. nab_python/resolve.py +497 -0
  60. nab_python/universal/__init__.py +1 -0
  61. nab_python/universal/matrix.py +235 -0
  62. nab_python/universal/provider.py +214 -0
  63. nab_python/universal/reresolve.py +310 -0
  64. nab_python/universal/resolve.py +508 -0
  65. nab_python/universal/validate.py +439 -0
  66. nab_python/universal/wheel_selection.py +327 -0
  67. nab_python/workspace.py +214 -0
  68. nab_python-0.0.1.dist-info/METADATA +49 -0
  69. nab_python-0.0.1.dist-info/RECORD +71 -0
  70. nab_python-0.0.1.dist-info/WHEEL +4 -0
  71. nab_python-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,302 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Mapping, Sequence
5
+
6
+ from .errors import _ErrorCollector
7
+ from .requirements import Requirement
8
+
9
+ __all__ = [
10
+ "CyclicDependencyGroup",
11
+ "DependencyGroupInclude",
12
+ "DependencyGroupResolver",
13
+ "DuplicateGroupNames",
14
+ "InvalidDependencyGroupObject",
15
+ "resolve_dependency_groups",
16
+ ]
17
+
18
+
19
+ def __dir__() -> list[str]:
20
+ return __all__
21
+
22
+
23
+ # -----------
24
+ # Error Types
25
+ # -----------
26
+
27
+
28
+ class DuplicateGroupNames(ValueError):
29
+ """
30
+ The same dependency groups were defined twice, with different non-normalized names.
31
+ """
32
+
33
+
34
+ class CyclicDependencyGroup(ValueError):
35
+ """
36
+ The dependency group includes form a cycle.
37
+ """
38
+
39
+ def __init__(self, requested_group: str, group: str, include_group: str) -> None:
40
+ self.requested_group = requested_group
41
+ self.group = group
42
+ self.include_group = include_group
43
+
44
+ if include_group == group:
45
+ reason = f"{group} includes itself"
46
+ else:
47
+ reason = f"{include_group} -> {group}, {group} -> {include_group}"
48
+ super().__init__(
49
+ "Cyclic dependency group include while resolving "
50
+ f"{requested_group}: {reason}"
51
+ )
52
+
53
+
54
+ # in the PEP 735 spec, the tables in dependency group lists were described as
55
+ # "Dependency Object Specifiers", but the only defined type of object was a
56
+ # "Dependency Group Include" -- hence the naming of this error as "Object"
57
+ class InvalidDependencyGroupObject(ValueError):
58
+ """
59
+ A member of a dependency group was identified as a dict, but was not in a valid
60
+ format.
61
+ """
62
+
63
+
64
+ # ------------------------
65
+ # Object Model & Interface
66
+ # ------------------------
67
+
68
+
69
+ class DependencyGroupInclude:
70
+ __slots__ = ("include_group",)
71
+
72
+ def __init__(self, include_group: str) -> None:
73
+ """
74
+ Initialize a DependencyGroupInclude.
75
+
76
+ :param include_group: The name of the group referred to by this include.
77
+ """
78
+ self.include_group = include_group
79
+
80
+ def __repr__(self) -> str:
81
+ return f"{self.__class__.__name__}({self.include_group!r})"
82
+
83
+
84
+ class DependencyGroupResolver:
85
+ """
86
+ A resolver for Dependency Group data.
87
+
88
+ This class handles caching, name normalization, cycle detection, and other
89
+ parsing requirements. There are only two public methods for exploring the data:
90
+ ``lookup()`` and ``resolve()``.
91
+
92
+ :param dependency_groups: A mapping, as provided via pyproject
93
+ ``[dependency-groups]``.
94
+ """
95
+
96
+ def __init__(
97
+ self,
98
+ dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
99
+ ) -> None:
100
+ errors = _ErrorCollector()
101
+
102
+ self.dependency_groups = _normalize_group_names(dependency_groups, errors)
103
+
104
+ # a map of group names to parsed data
105
+ self._parsed_groups: dict[
106
+ str, tuple[Requirement | DependencyGroupInclude, ...]
107
+ ] = {}
108
+ # a map of group names to their ancestors, used for cycle detection
109
+ self._include_graph_ancestors: dict[str, tuple[str, ...]] = {}
110
+ # a cache of completed resolutions to Requirement lists
111
+ self._resolve_cache: dict[str, tuple[Requirement, ...]] = {}
112
+
113
+ errors.finalize("[dependency-groups] data was invalid")
114
+
115
+ def lookup(self, group: str) -> tuple[Requirement | DependencyGroupInclude, ...]:
116
+ """
117
+ Lookup a group name, returning the parsed dependency data for that group.
118
+ This will not resolve includes.
119
+
120
+ :param group: the name of the group to lookup
121
+ """
122
+ group = _normalize_name(group)
123
+
124
+ with _ErrorCollector().on_exit(
125
+ f"[dependency-groups] data for {group!r} was malformed"
126
+ ) as errors:
127
+ return self._parse_group(group, errors)
128
+
129
+ def resolve(self, group: str) -> tuple[Requirement, ...]:
130
+ """
131
+ Resolve a dependency group to a list of requirements.
132
+
133
+ :param group: the name of the group to resolve
134
+ """
135
+ group = _normalize_name(group)
136
+
137
+ with _ErrorCollector().on_exit(
138
+ f"[dependency-groups] data for {group!r} was malformed"
139
+ ) as errors:
140
+ return self._resolve(group, group, errors)
141
+
142
+ def _resolve(
143
+ self, group: str, requested_group: str, errors: _ErrorCollector
144
+ ) -> tuple[Requirement, ...]:
145
+ """
146
+ This is a helper for cached resolution to strings. It preserves the name of the
147
+ group which the user initially requested in order to present a clearer error in
148
+ the event that a cycle is detected.
149
+
150
+ :param group: The normalized name of the group to resolve.
151
+ :param requested_group: The group which was used in the original, user-facing
152
+ request.
153
+ """
154
+ if group in self._resolve_cache:
155
+ return self._resolve_cache[group]
156
+
157
+ parsed = self._parse_group(group, errors)
158
+
159
+ resolved_group = []
160
+
161
+ for item in parsed:
162
+ if isinstance(item, Requirement):
163
+ resolved_group.append(item)
164
+ elif isinstance(item, DependencyGroupInclude):
165
+ include_group = _normalize_name(item.include_group)
166
+
167
+ # if a group is cyclic, record the error
168
+ # otherwise, follow the include_group reference
169
+ #
170
+ # this allows us to examine all includes in a group, even in the
171
+ # presence of errors
172
+ if include_group in self._include_graph_ancestors.get(group, ()):
173
+ errors.error(
174
+ CyclicDependencyGroup(
175
+ requested_group, group, item.include_group
176
+ )
177
+ )
178
+ else:
179
+ self._include_graph_ancestors[include_group] = (
180
+ *self._include_graph_ancestors.get(group, ()),
181
+ group,
182
+ )
183
+ resolved_group.extend(
184
+ self._resolve(include_group, requested_group, errors)
185
+ )
186
+ else: # pragma: no cover
187
+ raise NotImplementedError(
188
+ f"Invalid dependency group item after parse: {item}"
189
+ )
190
+
191
+ # in the event that errors were detected, present the group as empty and do not
192
+ # cache the result
193
+ # this ensures that repeated access to a cyclic group will raise multiple errors
194
+ if errors.errors:
195
+ return ()
196
+
197
+ self._resolve_cache[group] = tuple(resolved_group)
198
+ return self._resolve_cache[group]
199
+
200
+ def _parse_group(
201
+ self, group: str, errors: _ErrorCollector
202
+ ) -> tuple[Requirement | DependencyGroupInclude, ...]:
203
+ # short circuit -- never do the work twice
204
+ if group in self._parsed_groups:
205
+ return self._parsed_groups[group]
206
+
207
+ if group not in self.dependency_groups:
208
+ errors.error(LookupError(f"Dependency group '{group}' not found"))
209
+ return ()
210
+
211
+ raw_group = self.dependency_groups[group]
212
+ if isinstance(raw_group, str):
213
+ errors.error(
214
+ TypeError(
215
+ f"Dependency group {group!r} contained a string rather than a list."
216
+ )
217
+ )
218
+ return ()
219
+
220
+ if not isinstance(raw_group, Sequence):
221
+ errors.error(
222
+ TypeError(f"Dependency group {group!r} is not a sequence type.")
223
+ )
224
+ return ()
225
+
226
+ elements: list[Requirement | DependencyGroupInclude] = []
227
+ for item in raw_group:
228
+ if isinstance(item, str):
229
+ # packaging.requirements.Requirement parsing ensures that this is a
230
+ # valid PEP 508 Dependency Specifier
231
+ # raises InvalidRequirement on failure
232
+ elements.append(Requirement(item))
233
+ elif isinstance(item, Mapping):
234
+ if tuple(item.keys()) != ("include-group",):
235
+ errors.error(
236
+ InvalidDependencyGroupObject(
237
+ f"Invalid dependency group item: {item!r}"
238
+ )
239
+ )
240
+ else:
241
+ include_group = item["include-group"]
242
+ elements.append(DependencyGroupInclude(include_group=include_group))
243
+ else:
244
+ errors.error(TypeError(f"Invalid dependency group item: {item!r}"))
245
+
246
+ self._parsed_groups[group] = tuple(elements)
247
+ return self._parsed_groups[group]
248
+
249
+
250
+ # --------------------
251
+ # Functional Interface
252
+ # --------------------
253
+
254
+
255
+ def resolve_dependency_groups(
256
+ dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]], /, *groups: str
257
+ ) -> tuple[str, ...]:
258
+ """
259
+ Resolve a dependency group to a tuple of requirements, as strings.
260
+
261
+ :param dependency_groups: the parsed contents of the ``[dependency-groups]`` table
262
+ from ``pyproject.toml``
263
+ :param groups: the name of the group(s) to resolve
264
+ """
265
+ resolver = DependencyGroupResolver(dependency_groups)
266
+ return tuple(str(r) for group in groups for r in resolver.resolve(group))
267
+
268
+
269
+ # ----------------
270
+ # internal helpers
271
+ # ----------------
272
+
273
+
274
+ _NORMALIZE_PATTERN = re.compile(r"[-_.]+")
275
+
276
+
277
+ def _normalize_name(name: str) -> str:
278
+ return _NORMALIZE_PATTERN.sub("-", name).lower()
279
+
280
+
281
+ def _normalize_group_names(
282
+ dependency_groups: Mapping[str, Sequence[str | Mapping[str, str]]],
283
+ errors: _ErrorCollector,
284
+ ) -> dict[str, Sequence[str | Mapping[str, str]]]:
285
+ original_names: dict[str, list[str]] = {}
286
+ normalized_groups: dict[str, Sequence[str | Mapping[str, str]]] = {}
287
+
288
+ for group_name, value in dependency_groups.items():
289
+ normed_group_name = _normalize_name(group_name)
290
+ original_names.setdefault(normed_group_name, []).append(group_name)
291
+ normalized_groups[normed_group_name] = value
292
+
293
+ for normed_name, names in original_names.items():
294
+ if len(names) > 1:
295
+ errors.error(
296
+ DuplicateGroupNames(
297
+ "Duplicate dependency group names: "
298
+ f"{normed_name} ({', '.join(names)})"
299
+ )
300
+ )
301
+
302
+ return normalized_groups
@@ -0,0 +1,325 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import re
5
+ import urllib.parse
6
+ from collections.abc import Mapping
7
+ from typing import TYPE_CHECKING, Any, Protocol, TypeVar
8
+
9
+ if TYPE_CHECKING: # pragma: no cover
10
+ import sys
11
+ from collections.abc import Collection
12
+
13
+ if sys.version_info >= (3, 11):
14
+ from typing import Self
15
+ else:
16
+ from typing_extensions import Self
17
+
18
+ __all__ = [
19
+ "ArchiveInfo",
20
+ "DirInfo",
21
+ "DirectUrl",
22
+ "DirectUrlValidationError",
23
+ "VcsInfo",
24
+ ]
25
+
26
+
27
+ def __dir__() -> list[str]:
28
+ return __all__
29
+
30
+
31
+ _T = TypeVar("_T")
32
+
33
+
34
+ class _FromMappingProtocol(Protocol): # pragma: no cover
35
+ @classmethod
36
+ def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
37
+
38
+
39
+ _FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
40
+
41
+
42
+ def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
43
+ return {key: value for key, value in data if value is not None}
44
+
45
+
46
+ def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
47
+ """Get a value from the dictionary and verify it's the expected type."""
48
+ if (value := d.get(key)) is None:
49
+ return None
50
+ if not isinstance(value, expected_type):
51
+ raise DirectUrlValidationError(
52
+ f"Unexpected type {type(value).__name__} "
53
+ f"(expected {expected_type.__name__})",
54
+ context=key,
55
+ )
56
+ return value
57
+
58
+
59
+ def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
60
+ """Get a required value from the dictionary and verify it's the expected type."""
61
+ if (value := _get(d, expected_type, key)) is None:
62
+ raise _DirectUrlRequiredKeyError(key)
63
+ return value
64
+
65
+
66
+ def _get_object(
67
+ d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
68
+ ) -> _FromMappingProtocolT | None:
69
+ """Get a dictionary value from the dictionary and convert it to a dataclass."""
70
+ if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
71
+ return None
72
+ try:
73
+ return target_type._from_dict(value)
74
+ except Exception as e:
75
+ raise DirectUrlValidationError(e, context=key) from e
76
+
77
+
78
+ _PEP610_USER_PASS_ENV_VARS_REGEX = re.compile(
79
+ r"^\$\{[A-Za-z0-9-_]+\}(:\$\{[A-Za-z0-9-_]+\})?$"
80
+ )
81
+
82
+
83
+ def _strip_auth_from_netloc(netloc: str, safe_user_passwords: Collection[str]) -> str:
84
+ if "@" not in netloc:
85
+ return netloc
86
+ user_pass, netloc_no_user_pass = netloc.split("@", 1)
87
+ if user_pass in safe_user_passwords:
88
+ return netloc
89
+ if _PEP610_USER_PASS_ENV_VARS_REGEX.match(user_pass):
90
+ return netloc
91
+ return netloc_no_user_pass
92
+
93
+
94
+ def _strip_url(url: str, safe_user_passwords: Collection[str]) -> str:
95
+ """url with user:password part removed unless it is formed with
96
+ environment variables as specified in PEP 610, or it is a safe user:password
97
+ such as `git`.
98
+ """
99
+ parsed_url = urllib.parse.urlsplit(url)
100
+ netloc = _strip_auth_from_netloc(parsed_url.netloc, safe_user_passwords)
101
+ return urllib.parse.urlunsplit(
102
+ (
103
+ parsed_url.scheme,
104
+ netloc,
105
+ parsed_url.path,
106
+ parsed_url.query,
107
+ parsed_url.fragment,
108
+ )
109
+ )
110
+
111
+
112
+ class DirectUrlValidationError(Exception):
113
+ """Raised when when input data is not spec-compliant."""
114
+
115
+ context: str | None = None
116
+ message: str
117
+
118
+ def __init__(
119
+ self,
120
+ cause: str | Exception,
121
+ *,
122
+ context: str | None = None,
123
+ ) -> None:
124
+ if isinstance(cause, DirectUrlValidationError):
125
+ if cause.context:
126
+ self.context = (
127
+ f"{context}.{cause.context}" if context else cause.context
128
+ )
129
+ else:
130
+ self.context = context # pragma: no cover
131
+ self.message = cause.message
132
+ else:
133
+ self.context = context
134
+ self.message = str(cause)
135
+
136
+ def __str__(self) -> str:
137
+ if self.context:
138
+ return f"{self.message} in {self.context!r}"
139
+ return self.message
140
+
141
+
142
+ class _DirectUrlRequiredKeyError(DirectUrlValidationError):
143
+ def __init__(self, key: str) -> None:
144
+ super().__init__("Missing required value", context=key)
145
+
146
+
147
+ @dataclasses.dataclass(frozen=True, init=False)
148
+ class VcsInfo:
149
+ vcs: str
150
+ commit_id: str
151
+ requested_revision: str | None = None
152
+
153
+ def __init__(
154
+ self,
155
+ *,
156
+ vcs: str,
157
+ commit_id: str,
158
+ requested_revision: str | None = None,
159
+ ) -> None:
160
+ object.__setattr__(self, "vcs", vcs)
161
+ object.__setattr__(self, "commit_id", commit_id)
162
+ object.__setattr__(self, "requested_revision", requested_revision)
163
+
164
+ @classmethod
165
+ def _from_dict(cls, d: Mapping[str, Any]) -> Self:
166
+ # We can't validate vcs value because is not closed.
167
+ return cls(
168
+ vcs=_get_required(d, str, "vcs"),
169
+ requested_revision=_get(d, str, "requested_revision"),
170
+ commit_id=_get_required(d, str, "commit_id"),
171
+ )
172
+
173
+
174
+ @dataclasses.dataclass(frozen=True, init=False)
175
+ class ArchiveInfo:
176
+ hashes: Mapping[str, str] | None = None
177
+
178
+ def __init__(
179
+ self,
180
+ *,
181
+ hashes: Mapping[str, str] | None = None,
182
+ ) -> None:
183
+ object.__setattr__(self, "hashes", hashes)
184
+
185
+ @classmethod
186
+ def _from_dict(cls, d: Mapping[str, Any]) -> Self:
187
+ hashes = _get(d, Mapping, "hashes") # type: ignore[type-abstract]
188
+ if hashes is not None and not all(isinstance(h, str) for h in hashes.values()):
189
+ raise DirectUrlValidationError(
190
+ "Hash values must be strings", context="hashes"
191
+ )
192
+ legacy_hash = _get(d, str, "hash")
193
+ if legacy_hash is not None:
194
+ if "=" not in legacy_hash:
195
+ raise DirectUrlValidationError(
196
+ "Invalid hash format (expected '<algorithm>=<hash>')",
197
+ context="hash",
198
+ )
199
+ hash_algorithm, hash_value = legacy_hash.split("=", 1)
200
+ if hashes is None:
201
+ # if `hashes` are not present, we can derive it from the legacy `hash`
202
+ hashes = {hash_algorithm: hash_value}
203
+ else:
204
+ # if `hashes` are present, the legacy `hash` must match one of them
205
+ if hash_algorithm not in hashes:
206
+ raise DirectUrlValidationError(
207
+ f"Algorithm {hash_algorithm!r} used in hash field "
208
+ f"is not present in hashes field",
209
+ context="hashes",
210
+ )
211
+ if hashes[hash_algorithm] != hash_value:
212
+ raise DirectUrlValidationError(
213
+ f"Algorithm {hash_algorithm!r} used in hash field "
214
+ f"has different value in hashes field",
215
+ context="hash",
216
+ )
217
+ return cls(hashes=hashes)
218
+
219
+
220
+ @dataclasses.dataclass(frozen=True, init=False)
221
+ class DirInfo:
222
+ editable: bool | None = None
223
+
224
+ def __init__(
225
+ self,
226
+ *,
227
+ editable: bool | None = None,
228
+ ) -> None:
229
+ object.__setattr__(self, "editable", editable)
230
+
231
+ @classmethod
232
+ def _from_dict(cls, d: Mapping[str, Any]) -> Self:
233
+ return cls(
234
+ editable=_get(d, bool, "editable"),
235
+ )
236
+
237
+
238
+ @dataclasses.dataclass(frozen=True, init=False)
239
+ class DirectUrl:
240
+ """A class representing a direct URL."""
241
+
242
+ url: str
243
+ archive_info: ArchiveInfo | None = None
244
+ vcs_info: VcsInfo | None = None
245
+ dir_info: DirInfo | None = None
246
+ subdirectory: str | None = None # XXX Path or str?
247
+
248
+ def __init__(
249
+ self,
250
+ *,
251
+ url: str,
252
+ archive_info: ArchiveInfo | None = None,
253
+ vcs_info: VcsInfo | None = None,
254
+ dir_info: DirInfo | None = None,
255
+ subdirectory: str | None = None,
256
+ ) -> None:
257
+ object.__setattr__(self, "url", url)
258
+ object.__setattr__(self, "archive_info", archive_info)
259
+ object.__setattr__(self, "vcs_info", vcs_info)
260
+ object.__setattr__(self, "dir_info", dir_info)
261
+ object.__setattr__(self, "subdirectory", subdirectory)
262
+
263
+ @classmethod
264
+ def _from_dict(cls, d: Mapping[str, Any]) -> Self:
265
+ direct_url = cls(
266
+ url=_get_required(d, str, "url"),
267
+ archive_info=_get_object(d, ArchiveInfo, "archive_info"),
268
+ vcs_info=_get_object(d, VcsInfo, "vcs_info"),
269
+ dir_info=_get_object(d, DirInfo, "dir_info"),
270
+ subdirectory=_get(d, str, "subdirectory"),
271
+ )
272
+ if (
273
+ bool(direct_url.vcs_info)
274
+ + bool(direct_url.archive_info)
275
+ + bool(direct_url.dir_info)
276
+ ) != 1:
277
+ raise DirectUrlValidationError(
278
+ "Exactly one of vcs_info, archive_info, dir_info must be present"
279
+ )
280
+ if direct_url.dir_info is not None and not direct_url.url.startswith("file://"):
281
+ raise DirectUrlValidationError(
282
+ "URL scheme must be file:// when dir_info is present",
283
+ context="url",
284
+ )
285
+ # XXX subdirectory must be relative, can we, should we validate that here?
286
+ return direct_url
287
+
288
+ @classmethod
289
+ def from_dict(cls, d: Mapping[str, Any], /) -> Self:
290
+ """Create and validate a DirectUrl instance from a JSON dictionary."""
291
+ return cls._from_dict(d)
292
+
293
+ def to_dict(
294
+ self,
295
+ *,
296
+ generate_legacy_hash: bool = False,
297
+ strip_user_password: bool = True,
298
+ safe_user_passwords: Collection[str] = ("git",),
299
+ ) -> Mapping[str, Any]:
300
+ """Convert the DirectUrl instance to a JSON dictionary.
301
+
302
+ :param generate_legacy_hash: If True, include a legacy `hash` field in
303
+ `archive_info` for backward compatibility with tools that don't
304
+ support the `hashes` field.
305
+ :param strip_user_password: If True, strip user:password from the URL
306
+ unless it is formed with environment variables as specified in PEP
307
+ 610, or it is a safe user:password such as `git`.
308
+ :param safe_user_passwords: A collection of user:password strings that
309
+ should not be stripped from the URL even if `strip_user_password` is
310
+ True.
311
+ """
312
+ res = dataclasses.asdict(self, dict_factory=_json_dict_factory)
313
+ if generate_legacy_hash and self.archive_info and self.archive_info.hashes:
314
+ hash_algorithm, hash_value = next(iter(self.archive_info.hashes.items()))
315
+ res["archive_info"]["hash"] = f"{hash_algorithm}={hash_value}"
316
+ if strip_user_password:
317
+ res["url"] = _strip_url(self.url, safe_user_passwords)
318
+ return res
319
+
320
+ def validate(self) -> None:
321
+ """Validate the DirectUrl instance against the specification.
322
+
323
+ Raises :class:`DirectUrlValidationError` if invalid.
324
+ """
325
+ self.from_dict(self.to_dict())