Skip to content

Commit 941c15c

Browse files
committed
Add direct_url model and validator
1 parent 8f13a43 commit 941c15c

File tree

2 files changed

+466
-0
lines changed

2 files changed

+466
-0
lines changed

src/packaging/direct_url.py

Lines changed: 257 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
from __future__ import annotations
2+
3+
import dataclasses
4+
import sys
5+
from collections.abc import Mapping
6+
from dataclasses import dataclass
7+
from typing import TYPE_CHECKING, Any, Protocol, TypeVar
8+
9+
if TYPE_CHECKING: # pragma: no cover
10+
if sys.version_info >= (3, 11):
11+
from typing import Self
12+
else:
13+
from typing_extensions import Self
14+
15+
__all__ = [
16+
"ArchiveInfo",
17+
"DirInfo",
18+
"DirectUrl",
19+
"VcsInfo",
20+
]
21+
22+
_T = TypeVar("_T")
23+
24+
25+
class _FromMappingProtocol(Protocol): # pragma: no cover
26+
@classmethod
27+
def _from_dict(cls, d: Mapping[str, Any]) -> Self: ...
28+
29+
30+
_FromMappingProtocolT = TypeVar("_FromMappingProtocolT", bound=_FromMappingProtocol)
31+
32+
33+
def _json_dict_factory(data: list[tuple[str, Any]]) -> dict[str, Any]:
34+
return {key: value for key, value in data if value is not None}
35+
36+
37+
def _get(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T | None:
38+
"""Get a value from the dictionary and verify it's the expected type."""
39+
if (value := d.get(key)) is None:
40+
return None
41+
if not isinstance(value, expected_type):
42+
raise DirectUrlValidationError(
43+
f"Unexpected type {type(value).__name__} "
44+
f"(expected {expected_type.__name__})",
45+
context=key,
46+
)
47+
return value
48+
49+
50+
def _get_required(d: Mapping[str, Any], expected_type: type[_T], key: str) -> _T:
51+
"""Get a required value from the dictionary and verify it's the expected type."""
52+
if (value := _get(d, expected_type, key)) is None:
53+
raise _DirectUrlRequiredKeyError(key)
54+
return value
55+
56+
57+
def _get_object(
58+
d: Mapping[str, Any], target_type: type[_FromMappingProtocolT], key: str
59+
) -> _FromMappingProtocolT | None:
60+
"""Get a dictionary value from the dictionary and convert it to a dataclass."""
61+
if (value := _get(d, Mapping, key)) is None: # type: ignore[type-abstract]
62+
return None
63+
try:
64+
return target_type._from_dict(value)
65+
except Exception as e:
66+
raise DirectUrlValidationError(e, context=key) from e
67+
68+
69+
class DirectUrlValidationError(Exception):
70+
"""Raised when when input data is not spec-compliant."""
71+
72+
context: str | None = None
73+
message: str
74+
75+
def __init__(
76+
self,
77+
cause: str | Exception,
78+
*,
79+
context: str | None = None,
80+
) -> None:
81+
if isinstance(cause, DirectUrlValidationError):
82+
if cause.context:
83+
self.context = (
84+
f"{context}.{cause.context}" if context else cause.context
85+
)
86+
else:
87+
self.context = context # pragma: no cover
88+
self.message = cause.message
89+
else:
90+
self.context = context
91+
self.message = str(cause)
92+
93+
def __str__(self) -> str:
94+
if self.context:
95+
return f"{self.message} in {self.context!r}"
96+
return self.message
97+
98+
99+
class _DirectUrlRequiredKeyError(DirectUrlValidationError):
100+
def __init__(self, key: str) -> None:
101+
super().__init__("Missing required value", context=key)
102+
103+
104+
@dataclass(frozen=True, init=False)
105+
class VcsInfo:
106+
vcs: str
107+
commit_id: str
108+
requested_revision: str | None = None
109+
110+
def __init__(
111+
self,
112+
*,
113+
vcs: str,
114+
commit_id: str,
115+
requested_revision: str | None = None,
116+
) -> None:
117+
object.__setattr__(self, "vcs", vcs)
118+
object.__setattr__(self, "commit_id", commit_id)
119+
object.__setattr__(self, "requested_revision", requested_revision)
120+
121+
@classmethod
122+
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
123+
return cls(
124+
vcs=_get_required(d, str, "vcs"),
125+
requested_revision=_get(d, str, "requested_revision"),
126+
commit_id=_get_required(d, str, "commit_id"),
127+
)
128+
129+
130+
@dataclass(frozen=True, init=False)
131+
class ArchiveInfo:
132+
hashes: Mapping[str, str] | None = None
133+
hash: str | None = None # Deprecated, use `hashes` instead
134+
135+
def __init__(
136+
self,
137+
*,
138+
hashes: Mapping[str, str] | None = None,
139+
hash: str | None = None,
140+
) -> None:
141+
object.__setattr__(self, "hashes", hashes)
142+
object.__setattr__(self, "hash", hash)
143+
144+
@classmethod
145+
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
146+
archive_info = cls(
147+
hashes=_get(d, Mapping, "hashes"), # type: ignore[type-abstract]
148+
hash=_get(d, str, "hash"),
149+
)
150+
hashes = archive_info.hashes or {}
151+
if not all(isinstance(hash, str) for hash in hashes.values()):
152+
raise DirectUrlValidationError(
153+
"Hash values must be strings", context="hashes"
154+
)
155+
if archive_info.hash is not None:
156+
if "=" not in archive_info.hash:
157+
raise DirectUrlValidationError(
158+
"Invalid hash format (expected '<algorithm>=<hash>')",
159+
context="hash",
160+
)
161+
if archive_info.hashes is not None:
162+
# if `hashes` are present, the legacy `hash` must match one of them
163+
hash_algorithm, hash_value = archive_info.hash.split("=", 1)
164+
if hash_algorithm not in hashes:
165+
raise DirectUrlValidationError(
166+
f"Algorithm {hash_algorithm!r} used in hash field "
167+
f"is not present in hashes field",
168+
context="hashes",
169+
)
170+
if hashes[hash_algorithm] != hash_value:
171+
raise DirectUrlValidationError(
172+
f"Algorithm {hash_algorithm!r} used in hash field "
173+
f"has different value in hashes field",
174+
context="hash",
175+
)
176+
return archive_info
177+
178+
179+
@dataclass(frozen=True, init=False)
180+
class DirInfo:
181+
editable: bool | None = None
182+
183+
def __init__(
184+
self,
185+
*,
186+
editable: bool | None = None,
187+
) -> None:
188+
object.__setattr__(self, "editable", editable)
189+
190+
@classmethod
191+
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
192+
return cls(
193+
editable=_get(d, bool, "editable"),
194+
)
195+
196+
197+
@dataclass(frozen=True, init=False)
198+
class DirectUrl:
199+
url: str
200+
archive_info: ArchiveInfo | None = None
201+
vcs_info: VcsInfo | None = None
202+
dir_info: DirInfo | None = None
203+
subdirectory: str | None = None # XXX Path or str?
204+
205+
def __init__(
206+
self,
207+
*,
208+
url: str,
209+
archive_info: ArchiveInfo | None = None,
210+
vcs_info: VcsInfo | None = None,
211+
dir_info: DirInfo | None = None,
212+
subdirectory: str | None = None,
213+
) -> None:
214+
object.__setattr__(self, "url", url)
215+
object.__setattr__(self, "archive_info", archive_info)
216+
object.__setattr__(self, "vcs_info", vcs_info)
217+
object.__setattr__(self, "dir_info", dir_info)
218+
object.__setattr__(self, "subdirectory", subdirectory)
219+
220+
@classmethod
221+
def _from_dict(cls, d: Mapping[str, Any]) -> Self:
222+
direct_url = cls(
223+
url=_get_required(d, str, "url"),
224+
archive_info=_get_object(d, ArchiveInfo, "archive_info"),
225+
vcs_info=_get_object(d, VcsInfo, "vcs_info"),
226+
dir_info=_get_object(d, DirInfo, "dir_info"),
227+
subdirectory=_get(d, str, "subdirectory"),
228+
)
229+
if (
230+
bool(direct_url.vcs_info)
231+
+ bool(direct_url.archive_info)
232+
+ bool(direct_url.dir_info)
233+
) != 1:
234+
raise DirectUrlValidationError(
235+
"Exactly one of vcs_info, archive_info, dir_info must be present"
236+
)
237+
if direct_url.dir_info is not None and not direct_url.url.startswith("file://"):
238+
raise DirectUrlValidationError(
239+
"URL scheme must be file:// when dir_info is present",
240+
context="url",
241+
)
242+
# XXX subdirectory must be relative
243+
return direct_url
244+
245+
@classmethod
246+
def from_dict(cls, d: Mapping[str, Any], /) -> Self:
247+
return cls._from_dict(d)
248+
249+
def to_dict(self) -> Mapping[str, Any]:
250+
return dataclasses.asdict(self, dict_factory=_json_dict_factory)
251+
252+
def validate(self) -> None:
253+
"""Validate the DirectUrl instance against the specification.
254+
255+
Raises :class:`DirectUrlValidationError` otherwise.
256+
"""
257+
self.from_dict(self.to_dict())

0 commit comments

Comments
 (0)