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.
- nab_python/__init__.py +1 -0
- nab_python/_build/__init__.py +1 -0
- nab_python/_build/env.py +364 -0
- nab_python/_build/errors.py +17 -0
- nab_python/_build/runner.py +254 -0
- nab_python/_lockfile/__init__.py +1 -0
- nab_python/_lockfile/builder.py +339 -0
- nab_python/_lockfile/disjointness.py +207 -0
- nab_python/_lockfile/pylock.py +323 -0
- nab_python/_lockfile/requirements.py +121 -0
- nab_python/_packaging_provider.py +98 -0
- nab_python/_provider/__init__.py +1 -0
- nab_python/_provider/build_remote.py +95 -0
- nab_python/_provider/extras.py +231 -0
- nab_python/_provider/listing.py +442 -0
- nab_python/_provider/lookahead.py +156 -0
- nab_python/_provider/metadata_resolver.py +450 -0
- nab_python/_provider/priority.py +174 -0
- nab_python/_provider/sources.py +215 -0
- nab_python/_testing/__init__.py +1 -0
- nab_python/_testing/coordinator_fake.py +240 -0
- nab_python/_vcs_admission.py +209 -0
- nab_python/_vendor/__init__.py +6 -0
- nab_python/_vendor/packaging/LICENSE +3 -0
- nab_python/_vendor/packaging/LICENSE.APACHE +177 -0
- nab_python/_vendor/packaging/LICENSE.BSD +23 -0
- nab_python/_vendor/packaging/PROVENANCE.md +73 -0
- nab_python/_vendor/packaging/__init__.py +15 -0
- nab_python/_vendor/packaging/_elffile.py +108 -0
- nab_python/_vendor/packaging/_manylinux.py +265 -0
- nab_python/_vendor/packaging/_musllinux.py +88 -0
- nab_python/_vendor/packaging/_parser.py +394 -0
- nab_python/_vendor/packaging/_structures.py +33 -0
- nab_python/_vendor/packaging/_tokenizer.py +196 -0
- nab_python/_vendor/packaging/dependency_groups.py +302 -0
- nab_python/_vendor/packaging/direct_url.py +325 -0
- nab_python/_vendor/packaging/errors.py +94 -0
- nab_python/_vendor/packaging/licenses/__init__.py +186 -0
- nab_python/_vendor/packaging/licenses/_spdx.py +799 -0
- nab_python/_vendor/packaging/markers.py +506 -0
- nab_python/_vendor/packaging/metadata.py +964 -0
- nab_python/_vendor/packaging/py.typed +0 -0
- nab_python/_vendor/packaging/pylock.py +910 -0
- nab_python/_vendor/packaging/ranges.py +1803 -0
- nab_python/_vendor/packaging/requirements.py +132 -0
- nab_python/_vendor/packaging/specifiers.py +1141 -0
- nab_python/_vendor/packaging/tags.py +929 -0
- nab_python/_vendor/packaging/utils.py +296 -0
- nab_python/_vendor/packaging/version.py +1230 -0
- nab_python/build_backend.py +184 -0
- nab_python/config.py +805 -0
- nab_python/download.py +170 -0
- nab_python/fetch.py +827 -0
- nab_python/lockfile.py +238 -0
- nab_python/metadata.py +145 -0
- nab_python/provider.py +1235 -0
- nab_python/py.typed +0 -0
- nab_python/requirements_file.py +180 -0
- nab_python/resolve.py +497 -0
- nab_python/universal/__init__.py +1 -0
- nab_python/universal/matrix.py +235 -0
- nab_python/universal/provider.py +214 -0
- nab_python/universal/reresolve.py +310 -0
- nab_python/universal/resolve.py +508 -0
- nab_python/universal/validate.py +439 -0
- nab_python/universal/wheel_selection.py +327 -0
- nab_python/workspace.py +214 -0
- nab_python-0.0.1.dist-info/METADATA +49 -0
- nab_python-0.0.1.dist-info/RECORD +71 -0
- nab_python-0.0.1.dist-info/WHEEL +4 -0
- 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())
|