equiframe-benchmark 0.12.4__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.
- equiframe_benchmark/PROTOCOL_V2.md +172 -0
- equiframe_benchmark/__init__.py +284 -0
- equiframe_benchmark/__main__.py +3 -0
- equiframe_benchmark/_scale_workload.py +375 -0
- equiframe_benchmark/calibration_types.py +112 -0
- equiframe_benchmark/cli.py +196 -0
- equiframe_benchmark/commands.py +149 -0
- equiframe_benchmark/commands_scale.py +38 -0
- equiframe_benchmark/commands_step10.py +275 -0
- equiframe_benchmark/commands_v2.py +206 -0
- equiframe_benchmark/cost_table.py +62 -0
- equiframe_benchmark/costs.py +788 -0
- equiframe_benchmark/distribution.py +932 -0
- equiframe_benchmark/evaluation.py +614 -0
- equiframe_benchmark/evaluation_v2.py +428 -0
- equiframe_benchmark/fixtures.py +299 -0
- equiframe_benchmark/freeze.py +869 -0
- equiframe_benchmark/freeze_v2.py +412 -0
- equiframe_benchmark/integration.py +655 -0
- equiframe_benchmark/io.py +29 -0
- equiframe_benchmark/models.py +313 -0
- equiframe_benchmark/models_v2.py +13 -0
- equiframe_benchmark/mutations.py +34 -0
- equiframe_benchmark/oracle.py +264 -0
- equiframe_benchmark/pairwise.py +248 -0
- equiframe_benchmark/pilot.py +254 -0
- equiframe_benchmark/py.typed +0 -0
- equiframe_benchmark/reconciliation.py +294 -0
- equiframe_benchmark/reproduction.py +542 -0
- equiframe_benchmark/scale_io.py +19 -0
- equiframe_benchmark/scale_process.py +1077 -0
- equiframe_benchmark/scale_types.py +120 -0
- equiframe_benchmark/scaling.py +529 -0
- equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
- equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
- equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
- equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
- equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
- equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
- equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
- equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
- equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
- equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
- equiframe_benchmark/selection.py +178 -0
- equiframe_benchmark/snapshot.py +24 -0
- equiframe_benchmark/splits.py +92 -0
- equiframe_benchmark/study.py +526 -0
- equiframe_benchmark/study_bundle.py +379 -0
- equiframe_benchmark/study_bundle_v2.py +388 -0
- equiframe_benchmark/study_v2.py +544 -0
- equiframe_benchmark/tables.py +347 -0
- equiframe_benchmark/tables_v2.py +340 -0
- equiframe_benchmark/training_types.py +52 -0
- equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
- equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
- equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
- equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
- equiframe_benchmark-0.12.4.dist-info/licenses/THIRD_PARTY_NOTICES.md +8 -0
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"""Read-only distribution inventory, never publication or a PyPI name reservation.
|
|
2
|
+
|
|
3
|
+
Wheel metadata verification is distinct from building, installing and testing a
|
|
4
|
+
release. Platform evidence and publication decisions remain outside this reader.
|
|
5
|
+
Requirements are parsed by packaging; no backend or packaged code is executed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
13
|
+
|
|
14
|
+
from equiframe._path_compat import is_junction
|
|
15
|
+
|
|
16
|
+
if sys.version_info >= (3, 11):
|
|
17
|
+
import tomllib
|
|
18
|
+
else:
|
|
19
|
+
import tomli as tomllib
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from email import policy
|
|
22
|
+
from email.message import Message
|
|
23
|
+
from email.parser import BytesParser
|
|
24
|
+
from pathlib import Path, PurePosixPath
|
|
25
|
+
from typing import Annotated, Literal
|
|
26
|
+
from urllib.parse import urlsplit
|
|
27
|
+
from zipfile import ZipFile, ZipInfo
|
|
28
|
+
|
|
29
|
+
from equiframe._base import strict_bool_literal
|
|
30
|
+
from pydantic import BeforeValidator, Field, model_validator
|
|
31
|
+
from typing_extensions import Self
|
|
32
|
+
|
|
33
|
+
from equiframe_benchmark.models import Digest, Document, Text
|
|
34
|
+
|
|
35
|
+
MAX_SOURCE_FILE = 32 * 1024 * 1024
|
|
36
|
+
MAX_SOURCE_TOTAL = 128 * 1024 * 1024
|
|
37
|
+
MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
|
|
38
|
+
MAX_ARCHIVE_EXPANDED = 512 * 1024 * 1024
|
|
39
|
+
MAX_METADATA_BYTES = 2 * 1024 * 1024
|
|
40
|
+
MAX_MEMBERS = 10000
|
|
41
|
+
NonNegativeInt = Annotated[int, Field(ge=0)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NameCheck(Document):
|
|
45
|
+
"""Injected observation; an HTTP response is not evidence of ownership."""
|
|
46
|
+
|
|
47
|
+
name: Text
|
|
48
|
+
query_url: Text
|
|
49
|
+
fetched_at: datetime | None
|
|
50
|
+
http_status: Annotated[int, Field(ge=100, le=599)] | None
|
|
51
|
+
status: Literal["not_found", "exists", "unknown"]
|
|
52
|
+
controlled: bool = False
|
|
53
|
+
control_evidence: Text | None = None
|
|
54
|
+
|
|
55
|
+
@model_validator(mode="after")
|
|
56
|
+
def consistent(self) -> Self:
|
|
57
|
+
canonical = _name(self.name)
|
|
58
|
+
url = urlsplit(self.query_url)
|
|
59
|
+
if url.scheme != "https" or url.hostname != "pypi.org" or url.username or url.password:
|
|
60
|
+
raise ValueError("name observations require a credential-free official PyPI URL")
|
|
61
|
+
if url.path not in (f"/pypi/{canonical}/json", f"/project/{canonical}/") or (
|
|
62
|
+
url.query or url.fragment or url.port not in (None, 443)
|
|
63
|
+
):
|
|
64
|
+
raise ValueError("name observation URL must identify the declared distribution")
|
|
65
|
+
if self.fetched_at is not None and self.fetched_at.utcoffset() is None:
|
|
66
|
+
raise ValueError("name observations require a timezone-aware fetch time")
|
|
67
|
+
if self.status != "unknown":
|
|
68
|
+
expected = 404 if self.status == "not_found" else 200
|
|
69
|
+
if self.http_status != expected or self.fetched_at is None:
|
|
70
|
+
raise ValueError("a resolved name observation requires HTTP status and fetch time")
|
|
71
|
+
if self.controlled and (
|
|
72
|
+
self.status != "exists" or self.http_status != 200 or self.control_evidence is None
|
|
73
|
+
):
|
|
74
|
+
raise ValueError("name control requires separate evidence beyond an availability query")
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SourceFileEvidence(Document):
|
|
79
|
+
relative_path: Text
|
|
80
|
+
sha256: Digest
|
|
81
|
+
size_bytes: NonNegativeInt
|
|
82
|
+
|
|
83
|
+
@model_validator(mode="after")
|
|
84
|
+
def path(self) -> Self:
|
|
85
|
+
_relative(self.relative_path)
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class ProjectURL(Document):
|
|
90
|
+
label: Text
|
|
91
|
+
url: Text
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def valid_url(self) -> Self:
|
|
95
|
+
url = urlsplit(self.url)
|
|
96
|
+
if url.scheme not in ("https", "http") or not url.hostname or url.username or url.password:
|
|
97
|
+
raise ValueError("project URLs require an absolute credential-free HTTP(S) URL")
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class OptionalDependencies(Document):
|
|
102
|
+
extra: Text
|
|
103
|
+
requirements: tuple[Text, ...]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class ReadmeMetadata(Document):
|
|
107
|
+
file: Text | None
|
|
108
|
+
inline_text: str | None
|
|
109
|
+
content_type: Text | None
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="after")
|
|
112
|
+
def one_source(self) -> Self:
|
|
113
|
+
if (self.file is None) == (self.inline_text is None):
|
|
114
|
+
raise ValueError("readme metadata requires exactly one file or inline text")
|
|
115
|
+
if self.file is not None:
|
|
116
|
+
_relative(self.file)
|
|
117
|
+
if self.inline_text is not None and self.content_type is None:
|
|
118
|
+
raise ValueError("inline readme metadata requires content type")
|
|
119
|
+
return self
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class LicenseMetadata(Document):
|
|
123
|
+
expression: Text | None
|
|
124
|
+
file: Text | None
|
|
125
|
+
text: str | None
|
|
126
|
+
|
|
127
|
+
@model_validator(mode="after")
|
|
128
|
+
def defined_license(self) -> Self:
|
|
129
|
+
if sum(value is not None for value in (self.expression, self.file, self.text)) != 1:
|
|
130
|
+
raise ValueError("license metadata requires one expression, file or legacy text")
|
|
131
|
+
if self.file is not None:
|
|
132
|
+
_relative(self.file)
|
|
133
|
+
if self.text is not None and not self.text.strip():
|
|
134
|
+
raise ValueError("legacy license text must not be empty")
|
|
135
|
+
if self.expression is not None:
|
|
136
|
+
from packaging.licenses import canonicalize_license_expression
|
|
137
|
+
|
|
138
|
+
if canonicalize_license_expression(self.expression) != self.expression:
|
|
139
|
+
raise ValueError("license expressions must use canonical SPDX identifiers")
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ProjectDistributionMetadata(Document):
|
|
144
|
+
name: Text
|
|
145
|
+
canonical_name: Text
|
|
146
|
+
version: Text
|
|
147
|
+
requires_python: Text | None
|
|
148
|
+
dependencies: tuple[Text, ...]
|
|
149
|
+
optional_dependencies: tuple[OptionalDependencies, ...]
|
|
150
|
+
classifiers: tuple[Text, ...]
|
|
151
|
+
urls: tuple[ProjectURL, ...]
|
|
152
|
+
readme: ReadmeMetadata | None
|
|
153
|
+
license: LicenseMetadata | None
|
|
154
|
+
license_file_patterns: tuple[Text, ...]
|
|
155
|
+
license_files: tuple[Text, ...]
|
|
156
|
+
typed_markers: tuple[Text, ...]
|
|
157
|
+
|
|
158
|
+
@model_validator(mode="after")
|
|
159
|
+
def metadata(self) -> Self:
|
|
160
|
+
from packaging.version import Version
|
|
161
|
+
|
|
162
|
+
if self.canonical_name != _name(self.name) or self.version != str(Version(self.version)):
|
|
163
|
+
raise ValueError("project name and version must use their canonical metadata forms")
|
|
164
|
+
if self.requires_python is not None and self.requires_python != _spec(self.requires_python):
|
|
165
|
+
raise ValueError("Requires-Python must use canonical specifiers")
|
|
166
|
+
for requirement in self.dependencies:
|
|
167
|
+
if requirement != _requirement(requirement):
|
|
168
|
+
raise ValueError("dependencies must use canonical parsed requirements")
|
|
169
|
+
extras = tuple(item.extra for item in self.optional_dependencies)
|
|
170
|
+
if len(set(extras)) != len(extras):
|
|
171
|
+
raise ValueError("optional extras must be unique after normalization")
|
|
172
|
+
for item in self.optional_dependencies:
|
|
173
|
+
if _name(item.extra) != item.extra:
|
|
174
|
+
raise ValueError("extra names must be normalized")
|
|
175
|
+
for requirement in item.requirements:
|
|
176
|
+
if requirement != _requirement(requirement):
|
|
177
|
+
raise ValueError("optional requirements must be canonical")
|
|
178
|
+
for filename in (*self.license_files, *self.typed_markers):
|
|
179
|
+
_relative(filename)
|
|
180
|
+
return self
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class WheelDistributionMetadata(Document):
|
|
184
|
+
wheel_sha256: Digest
|
|
185
|
+
wheel_size_bytes: NonNegativeInt
|
|
186
|
+
metadata_sha256: Digest
|
|
187
|
+
wheel_metadata_sha256: Digest
|
|
188
|
+
metadata_version: Text
|
|
189
|
+
name: Text
|
|
190
|
+
canonical_name: Text
|
|
191
|
+
version: Text
|
|
192
|
+
requires_python: Text | None
|
|
193
|
+
dependencies: tuple[Text, ...]
|
|
194
|
+
extras: tuple[Text, ...]
|
|
195
|
+
classifiers: tuple[Text, ...]
|
|
196
|
+
urls: tuple[ProjectURL, ...]
|
|
197
|
+
license_expression: Text | None
|
|
198
|
+
license_text: Text | None
|
|
199
|
+
license_files: tuple[Text, ...]
|
|
200
|
+
typed_markers: tuple[Text, ...]
|
|
201
|
+
wheel_version: Literal["1.0"]
|
|
202
|
+
root_is_purelib: bool
|
|
203
|
+
tags: Annotated[tuple[Text, ...], Field(min_length=1)]
|
|
204
|
+
member_count: Annotated[int, Field(ge=1, le=MAX_MEMBERS)]
|
|
205
|
+
expanded_size_bytes: Annotated[int, Field(ge=0, le=MAX_ARCHIVE_EXPANDED)]
|
|
206
|
+
|
|
207
|
+
@model_validator(mode="after")
|
|
208
|
+
def canonical_metadata(self) -> Self:
|
|
209
|
+
from packaging.version import Version
|
|
210
|
+
|
|
211
|
+
if self.canonical_name != _name(self.name) or self.version != str(Version(self.version)):
|
|
212
|
+
raise ValueError("wheel name/version must use canonical metadata forms")
|
|
213
|
+
if self.requires_python is not None and self.requires_python != _spec(self.requires_python):
|
|
214
|
+
raise ValueError("wheel Requires-Python must be canonical")
|
|
215
|
+
if self.metadata_version not in (
|
|
216
|
+
"1.0",
|
|
217
|
+
"1.1",
|
|
218
|
+
"1.2",
|
|
219
|
+
"2.1",
|
|
220
|
+
"2.2",
|
|
221
|
+
"2.3",
|
|
222
|
+
"2.4",
|
|
223
|
+
"2.5",
|
|
224
|
+
"2.6",
|
|
225
|
+
):
|
|
226
|
+
raise ValueError("unsupported distribution Metadata-Version")
|
|
227
|
+
for requirement in self.dependencies:
|
|
228
|
+
if requirement != _requirement(requirement):
|
|
229
|
+
raise ValueError("wheel dependencies must use canonical parsed requirements")
|
|
230
|
+
for path in (*self.typed_markers, *self.license_files):
|
|
231
|
+
_relative(path)
|
|
232
|
+
return self
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class DistributionReadiness(Document):
|
|
236
|
+
package_directory: Text
|
|
237
|
+
project: ProjectDistributionMetadata
|
|
238
|
+
source_files: tuple[SourceFileEvidence, ...]
|
|
239
|
+
source_manifest_sha256: Digest
|
|
240
|
+
lock_sha256: Digest | None
|
|
241
|
+
wheel: WheelDistributionMetadata | None
|
|
242
|
+
name_check: NameCheck
|
|
243
|
+
pending_conditions: tuple[Text, ...]
|
|
244
|
+
source_available: Annotated[Literal[True], BeforeValidator(strict_bool_literal)] = True
|
|
245
|
+
wheel_metadata_verified: bool
|
|
246
|
+
technical_inventory_complete: bool
|
|
247
|
+
build_install_verified: Annotated[Literal[False], BeforeValidator(strict_bool_literal)] = False
|
|
248
|
+
publication_ready: Annotated[Literal[False], BeforeValidator(strict_bool_literal)] = False
|
|
249
|
+
|
|
250
|
+
@model_validator(mode="after")
|
|
251
|
+
def evidence(self) -> Self:
|
|
252
|
+
paths = tuple(item.relative_path for item in self.source_files)
|
|
253
|
+
if paths != tuple(sorted(set(paths))) or "pyproject.toml" not in paths:
|
|
254
|
+
raise ValueError("source inventory requires sorted unique paths and pyproject.toml")
|
|
255
|
+
files = {item.relative_path: item for item in self.source_files}
|
|
256
|
+
if any(
|
|
257
|
+
path not in files for path in (*self.project.typed_markers, *self.project.license_files)
|
|
258
|
+
):
|
|
259
|
+
raise ValueError("declared typing/license files must belong to the source inventory")
|
|
260
|
+
lock = files.get("uv.lock")
|
|
261
|
+
if self.lock_sha256 != (lock.sha256 if lock else None):
|
|
262
|
+
raise ValueError("lock fingerprint disagrees with source inventory")
|
|
263
|
+
if self.source_manifest_sha256 != _inventory_digest(self.source_files):
|
|
264
|
+
raise ValueError("source fingerprint disagrees with its inventory")
|
|
265
|
+
if _name(self.name_check.name) != self.project.canonical_name:
|
|
266
|
+
raise ValueError("the name observation belongs to a different distribution")
|
|
267
|
+
if self.wheel is not None:
|
|
268
|
+
_check_wheel(self.project, self.wheel)
|
|
269
|
+
pending = _pending(self.project, files, self.wheel, self.name_check)
|
|
270
|
+
technical = not any(
|
|
271
|
+
condition in pending
|
|
272
|
+
for condition in (
|
|
273
|
+
"pending_readme",
|
|
274
|
+
"pending_lock",
|
|
275
|
+
"pending_requires_python",
|
|
276
|
+
"pending_typing_marker",
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
if (
|
|
280
|
+
self.pending_conditions != pending
|
|
281
|
+
or self.wheel_metadata_verified != (self.wheel is not None)
|
|
282
|
+
or self.technical_inventory_complete != technical
|
|
283
|
+
):
|
|
284
|
+
raise ValueError("readiness flags must agree with the observed inventory")
|
|
285
|
+
return self
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _name(value: str) -> str:
|
|
289
|
+
from packaging.utils import canonicalize_name
|
|
290
|
+
|
|
291
|
+
return canonicalize_name(value, validate=True)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _requirement(value: str) -> str:
|
|
295
|
+
from packaging.requirements import Requirement
|
|
296
|
+
|
|
297
|
+
parsed = Requirement(value)
|
|
298
|
+
parsed.name = _name(parsed.name)
|
|
299
|
+
parsed.extras = {_name(extra) for extra in parsed.extras}
|
|
300
|
+
return str(parsed)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _optional_requirement(value: str, extra: str) -> str:
|
|
304
|
+
from packaging.markers import Marker
|
|
305
|
+
from packaging.requirements import Requirement
|
|
306
|
+
|
|
307
|
+
requirement = Requirement(value)
|
|
308
|
+
marker = f"extra == '{extra}'"
|
|
309
|
+
if requirement.marker is not None:
|
|
310
|
+
marker = f"({requirement.marker}) and {marker}"
|
|
311
|
+
requirement.marker = Marker(marker)
|
|
312
|
+
return _requirement(str(requirement))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _spec(value: str) -> str:
|
|
316
|
+
from packaging.specifiers import SpecifierSet
|
|
317
|
+
|
|
318
|
+
if not isinstance(value, str) or not value.strip():
|
|
319
|
+
raise ValueError("Requires-Python cannot be an empty compatibility claim")
|
|
320
|
+
return str(SpecifierSet(value))
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _relative(value: str, *, pattern: bool = False) -> PurePosixPath:
|
|
324
|
+
if not isinstance(value, str):
|
|
325
|
+
raise ValueError("artifact paths must be strings")
|
|
326
|
+
path = PurePosixPath(value)
|
|
327
|
+
if (
|
|
328
|
+
not value
|
|
329
|
+
or any(ord(character) < 32 or ord(character) == 127 for character in value)
|
|
330
|
+
or "\\" in value
|
|
331
|
+
or ":" in value
|
|
332
|
+
or path.is_absolute()
|
|
333
|
+
or any(part in ("", ".", "..") for part in value.split("/"))
|
|
334
|
+
or (not pattern and any(char in value for char in "*?[]"))
|
|
335
|
+
):
|
|
336
|
+
raise ValueError("artifact paths must stay inside their source/archive root")
|
|
337
|
+
return path
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _file(root: Path, relative: str) -> Path:
|
|
341
|
+
parts = _relative(relative).parts
|
|
342
|
+
path = root.joinpath(*parts)
|
|
343
|
+
for parent in (path, *path.parents):
|
|
344
|
+
if parent == root:
|
|
345
|
+
break
|
|
346
|
+
if parent.is_symlink() or is_junction(parent):
|
|
347
|
+
raise ValueError("inventory does not follow source symlinks")
|
|
348
|
+
if not path.resolve().is_relative_to(root):
|
|
349
|
+
raise ValueError("source artifact escapes the package directory")
|
|
350
|
+
if not path.is_file() or not stat.S_ISREG(path.stat().st_mode):
|
|
351
|
+
raise ValueError("source artifacts must be regular files")
|
|
352
|
+
return path
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _sha_file(path: Path, limit: int) -> tuple[str, int]:
|
|
356
|
+
size = path.stat().st_size
|
|
357
|
+
if size > limit:
|
|
358
|
+
raise ValueError("artifact exceeds inventory size limit")
|
|
359
|
+
fingerprint = hashlib.sha256()
|
|
360
|
+
observed = 0
|
|
361
|
+
with path.open("rb") as stream:
|
|
362
|
+
while block := stream.read(1024 * 1024):
|
|
363
|
+
observed += len(block)
|
|
364
|
+
if observed > limit:
|
|
365
|
+
raise ValueError("artifact exceeds inventory size limit")
|
|
366
|
+
fingerprint.update(block)
|
|
367
|
+
if observed != size:
|
|
368
|
+
raise ValueError("artifact changed size while being inventoried")
|
|
369
|
+
return fingerprint.hexdigest(), observed
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _inventory_digest(files: tuple[SourceFileEvidence, ...]) -> str:
|
|
373
|
+
return hashlib.sha256(
|
|
374
|
+
json.dumps(
|
|
375
|
+
[item.model_dump(mode="json") for item in files],
|
|
376
|
+
sort_keys=True,
|
|
377
|
+
separators=(",", ":"),
|
|
378
|
+
ensure_ascii=True,
|
|
379
|
+
allow_nan=False,
|
|
380
|
+
).encode()
|
|
381
|
+
).hexdigest()
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _texts(value: object) -> tuple[str, ...]:
|
|
385
|
+
if not isinstance(value, list) or any(not isinstance(item, str) for item in value):
|
|
386
|
+
raise ValueError("project metadata collections must contain strings")
|
|
387
|
+
return tuple(value)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _readme(value: object) -> ReadmeMetadata | None:
|
|
391
|
+
if value is None:
|
|
392
|
+
return None
|
|
393
|
+
if isinstance(value, str):
|
|
394
|
+
_relative(value)
|
|
395
|
+
content_type = {".md": "text/markdown", ".rst": "text/x-rst"}.get(Path(value).suffix)
|
|
396
|
+
return ReadmeMetadata(file=value, inline_text=None, content_type=content_type)
|
|
397
|
+
if isinstance(value, dict) and set(value) <= {"file", "text", "content-type"}:
|
|
398
|
+
if ("file" in value) == ("text" in value) or "content-type" not in value:
|
|
399
|
+
raise ValueError("readme table requires file or text and content-type")
|
|
400
|
+
if "file" in value:
|
|
401
|
+
_relative(value["file"])
|
|
402
|
+
return ReadmeMetadata(
|
|
403
|
+
file=value.get("file"),
|
|
404
|
+
inline_text=value.get("text"),
|
|
405
|
+
content_type=value["content-type"],
|
|
406
|
+
)
|
|
407
|
+
raise ValueError("invalid project readme metadata")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _license(value: object) -> LicenseMetadata | None:
|
|
411
|
+
if value is None:
|
|
412
|
+
return None
|
|
413
|
+
if isinstance(value, str):
|
|
414
|
+
from packaging.licenses import canonicalize_license_expression
|
|
415
|
+
|
|
416
|
+
return LicenseMetadata(
|
|
417
|
+
expression=canonicalize_license_expression(value), file=None, text=None
|
|
418
|
+
)
|
|
419
|
+
if isinstance(value, dict) and set(value) in ({"file"}, {"text"}):
|
|
420
|
+
if "file" in value:
|
|
421
|
+
_relative(value["file"])
|
|
422
|
+
return LicenseMetadata(expression=None, file=value.get("file"), text=value.get("text"))
|
|
423
|
+
raise ValueError("invalid project license metadata")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _source(
|
|
427
|
+
root: Path, wheel: WheelDistributionMetadata | None = None
|
|
428
|
+
) -> tuple[ProjectDistributionMetadata, tuple[SourceFileEvidence, ...]]:
|
|
429
|
+
from packaging.version import Version
|
|
430
|
+
|
|
431
|
+
pyproject = _file(root, "pyproject.toml")
|
|
432
|
+
if pyproject.stat().st_size > MAX_METADATA_BYTES:
|
|
433
|
+
raise ValueError("project metadata exceeds inventory size limit")
|
|
434
|
+
pyproject_bytes = pyproject.read_bytes()
|
|
435
|
+
parsed = tomllib.loads(pyproject_bytes.decode("utf-8", errors="strict"))
|
|
436
|
+
project = parsed.get("project")
|
|
437
|
+
if not isinstance(project, dict):
|
|
438
|
+
raise ValueError("pyproject requires a static project table")
|
|
439
|
+
dynamic = _texts(project.get("dynamic", []))
|
|
440
|
+
if any(
|
|
441
|
+
key in dynamic
|
|
442
|
+
for key in ("name", "dependencies", "optional-dependencies", "requires-python")
|
|
443
|
+
):
|
|
444
|
+
raise ValueError("dynamic metadata cannot be verified without executing a backend")
|
|
445
|
+
if not isinstance(project.get("name"), str):
|
|
446
|
+
raise ValueError("project name must be a string")
|
|
447
|
+
name = project["name"]
|
|
448
|
+
pkg_info_bytes = None
|
|
449
|
+
if "version" in dynamic:
|
|
450
|
+
if "version" in project:
|
|
451
|
+
raise ValueError("project version cannot be both static and dynamic")
|
|
452
|
+
if wheel is not None and wheel.canonical_name != _name(name):
|
|
453
|
+
raise ValueError("identified wheel name disagrees with dynamic project")
|
|
454
|
+
pkg_info = root / "PKG-INFO"
|
|
455
|
+
if pkg_info.exists() or pkg_info.is_symlink():
|
|
456
|
+
pkg_info = _file(root, "PKG-INFO")
|
|
457
|
+
if pkg_info.stat().st_size > MAX_METADATA_BYTES:
|
|
458
|
+
raise ValueError("sdist metadata exceeds inventory size limit")
|
|
459
|
+
with pkg_info.open("rb") as stream:
|
|
460
|
+
pkg_info_bytes = stream.read(MAX_METADATA_BYTES + 1)
|
|
461
|
+
if len(pkg_info_bytes) > MAX_METADATA_BYTES:
|
|
462
|
+
raise ValueError("sdist metadata exceeds inventory size limit")
|
|
463
|
+
message = _headers(pkg_info_bytes)
|
|
464
|
+
if _name(_required(message, "Name")) != _name(name):
|
|
465
|
+
raise ValueError("sdist metadata name disagrees with dynamic project")
|
|
466
|
+
version = str(Version(_required(message, "Version")))
|
|
467
|
+
if wheel is not None and version != wheel.version:
|
|
468
|
+
raise ValueError("sdist metadata version disagrees with identified wheel")
|
|
469
|
+
elif wheel is not None:
|
|
470
|
+
version = wheel.version
|
|
471
|
+
else:
|
|
472
|
+
raise ValueError(
|
|
473
|
+
"dynamic version requires identified wheel or sdist PKG-INFO; "
|
|
474
|
+
"it cannot be verified without executing a backend"
|
|
475
|
+
)
|
|
476
|
+
else:
|
|
477
|
+
if not isinstance(project.get("version"), str):
|
|
478
|
+
raise ValueError("project static version must be a string")
|
|
479
|
+
version = str(Version(project["version"]))
|
|
480
|
+
dependencies = tuple(sorted(_requirement(s) for s in _texts(project.get("dependencies", []))))
|
|
481
|
+
optional = project.get("optional-dependencies", {})
|
|
482
|
+
urls = project.get("urls", {})
|
|
483
|
+
if not isinstance(optional, dict) or not isinstance(urls, dict):
|
|
484
|
+
raise ValueError("optional dependencies and project URLs must be tables")
|
|
485
|
+
optional_rows = tuple(
|
|
486
|
+
sorted(
|
|
487
|
+
(
|
|
488
|
+
OptionalDependencies(
|
|
489
|
+
extra=_name(extra),
|
|
490
|
+
requirements=tuple(sorted(_requirement(s) for s in _texts(requirements))),
|
|
491
|
+
)
|
|
492
|
+
for extra, requirements in optional.items()
|
|
493
|
+
),
|
|
494
|
+
key=lambda row: row.extra,
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
readme = _readme(project.get("readme"))
|
|
498
|
+
license_metadata = _license(project.get("license"))
|
|
499
|
+
patterns = _texts(
|
|
500
|
+
project.get("license-files", ["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", "AUTHORS*"])
|
|
501
|
+
)
|
|
502
|
+
licenses: set[str] = set()
|
|
503
|
+
for pattern in patterns:
|
|
504
|
+
_relative(pattern, pattern=True)
|
|
505
|
+
for match in root.glob(pattern):
|
|
506
|
+
relative = match.relative_to(root).as_posix()
|
|
507
|
+
_file(root, relative)
|
|
508
|
+
licenses.add(relative)
|
|
509
|
+
if (
|
|
510
|
+
license_metadata is not None
|
|
511
|
+
and license_metadata.file is not None
|
|
512
|
+
and root.joinpath(license_metadata.file).exists()
|
|
513
|
+
):
|
|
514
|
+
licenses.add(license_metadata.file)
|
|
515
|
+
files: set[str] = {"pyproject.toml"}
|
|
516
|
+
if pkg_info_bytes is not None:
|
|
517
|
+
files.add("PKG-INFO")
|
|
518
|
+
if (root / "uv.lock").exists():
|
|
519
|
+
files.add("uv.lock")
|
|
520
|
+
if readme is not None and readme.file is not None and root.joinpath(readme.file).exists():
|
|
521
|
+
files.add(readme.file)
|
|
522
|
+
files.update(licenses)
|
|
523
|
+
source = root / "src"
|
|
524
|
+
if source.is_symlink() or is_junction(source):
|
|
525
|
+
raise ValueError("inventory does not follow a source-directory symlink")
|
|
526
|
+
if source.exists():
|
|
527
|
+
for scanned, path in enumerate(source.rglob("*"), start=1):
|
|
528
|
+
if scanned > MAX_MEMBERS:
|
|
529
|
+
raise ValueError("source inventory exceeds entry count limit")
|
|
530
|
+
if path.is_symlink() or is_junction(path):
|
|
531
|
+
raise ValueError("inventory does not follow source symlinks")
|
|
532
|
+
if path.is_file() and "__pycache__" not in path.parts:
|
|
533
|
+
files.add(path.relative_to(root).as_posix())
|
|
534
|
+
if len(files) > MAX_MEMBERS:
|
|
535
|
+
raise ValueError("source inventory exceeds file count limit")
|
|
536
|
+
if len(files) > MAX_MEMBERS:
|
|
537
|
+
raise ValueError("source inventory exceeds file count limit")
|
|
538
|
+
collected: list[SourceFileEvidence] = []
|
|
539
|
+
total = 0
|
|
540
|
+
for relative in sorted(files):
|
|
541
|
+
fingerprint, size = _sha_file(
|
|
542
|
+
_file(root, relative),
|
|
543
|
+
min(MAX_SOURCE_FILE, MAX_SOURCE_TOTAL - total),
|
|
544
|
+
)
|
|
545
|
+
total += size
|
|
546
|
+
collected.append(
|
|
547
|
+
SourceFileEvidence(relative_path=relative, sha256=fingerprint, size_bytes=size)
|
|
548
|
+
)
|
|
549
|
+
evidence = tuple(collected)
|
|
550
|
+
if next(item.sha256 for item in evidence if item.relative_path == "pyproject.toml") != (
|
|
551
|
+
hashlib.sha256(pyproject_bytes).hexdigest()
|
|
552
|
+
):
|
|
553
|
+
raise ValueError("project metadata changed while being inventoried")
|
|
554
|
+
metadata = ProjectDistributionMetadata(
|
|
555
|
+
name=name,
|
|
556
|
+
canonical_name=_name(name),
|
|
557
|
+
version=version,
|
|
558
|
+
requires_python=_spec(project["requires-python"]) if "requires-python" in project else None,
|
|
559
|
+
dependencies=dependencies,
|
|
560
|
+
optional_dependencies=optional_rows,
|
|
561
|
+
classifiers=tuple(sorted(_texts(project.get("classifiers", [])))),
|
|
562
|
+
urls=tuple(ProjectURL(label=label, url=url) for label, url in sorted(urls.items())),
|
|
563
|
+
readme=readme,
|
|
564
|
+
license=license_metadata,
|
|
565
|
+
license_file_patterns=patterns,
|
|
566
|
+
license_files=tuple(sorted(licenses)),
|
|
567
|
+
typed_markers=tuple(
|
|
568
|
+
relative for relative in sorted(files) if relative.endswith("/py.typed")
|
|
569
|
+
),
|
|
570
|
+
)
|
|
571
|
+
if pkg_info_bytes is not None:
|
|
572
|
+
if next(item.sha256 for item in evidence if item.relative_path == "PKG-INFO") != (
|
|
573
|
+
hashlib.sha256(pkg_info_bytes).hexdigest()
|
|
574
|
+
):
|
|
575
|
+
raise ValueError("sdist metadata changed while being inventoried")
|
|
576
|
+
_check_pkg_info(metadata, _headers(pkg_info_bytes))
|
|
577
|
+
return metadata, evidence
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _headers(data: bytes) -> Message:
|
|
581
|
+
data.decode("utf-8", errors="strict")
|
|
582
|
+
parsed = BytesParser(policy=policy.compat32).parsebytes(data)
|
|
583
|
+
if parsed.defects:
|
|
584
|
+
raise ValueError("malformed email-formatted distribution metadata")
|
|
585
|
+
return parsed
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _single(message: Message, field: str, *, required: bool = True) -> str | None:
|
|
589
|
+
values = message.get_all(field, [])
|
|
590
|
+
if len(values) > 1 or (required and len(values) != 1):
|
|
591
|
+
raise ValueError(f"metadata requires exactly one {field} header")
|
|
592
|
+
return str(values[0]).strip() if values else None
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _required(message: Message, field: str) -> str:
|
|
596
|
+
value = _single(message, field)
|
|
597
|
+
if value is None or not value:
|
|
598
|
+
raise ValueError(f"missing required {field} metadata")
|
|
599
|
+
return value
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _members(archive: ZipFile) -> tuple[ZipInfo, ...]:
|
|
603
|
+
members = tuple(archive.infolist())
|
|
604
|
+
if not members or len(members) > MAX_MEMBERS:
|
|
605
|
+
raise ValueError("wheel archive exceeds member count limit")
|
|
606
|
+
names: set[str] = set()
|
|
607
|
+
total = 0
|
|
608
|
+
for member in members:
|
|
609
|
+
# ZipInfo normalizes backslashes to slashes on Windows. Check the raw
|
|
610
|
+
# central-directory spelling as well, before accepting a portable path.
|
|
611
|
+
original = member.orig_filename
|
|
612
|
+
_relative(original.rstrip("/") if member.is_dir() else original)
|
|
613
|
+
_relative(member.filename.rstrip("/") if member.is_dir() else member.filename)
|
|
614
|
+
if member.filename in names:
|
|
615
|
+
raise ValueError("wheel archive contains duplicate paths")
|
|
616
|
+
names.add(member.filename)
|
|
617
|
+
if member.flag_bits & 1 or stat.S_ISLNK(member.external_attr >> 16):
|
|
618
|
+
raise ValueError("encrypted and symlink wheel members are unsupported")
|
|
619
|
+
total += member.file_size
|
|
620
|
+
if total > MAX_ARCHIVE_EXPANDED:
|
|
621
|
+
raise ValueError("wheel archive exceeds expanded size limit")
|
|
622
|
+
return members
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _read_member(archive: ZipFile, name: str) -> bytes:
|
|
626
|
+
entry = archive.getinfo(name)
|
|
627
|
+
if entry.file_size > MAX_METADATA_BYTES:
|
|
628
|
+
raise ValueError("wheel metadata exceeds inventory size limit")
|
|
629
|
+
with archive.open(entry) as stream:
|
|
630
|
+
data = stream.read(MAX_METADATA_BYTES + 1)
|
|
631
|
+
if len(data) != entry.file_size or len(data) > MAX_METADATA_BYTES:
|
|
632
|
+
raise ValueError("wheel metadata size does not match its directory entry")
|
|
633
|
+
return data
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def _wheel(path: Path) -> WheelDistributionMetadata:
|
|
637
|
+
from packaging.tags import parse_tag
|
|
638
|
+
from packaging.utils import parse_wheel_filename
|
|
639
|
+
from packaging.version import Version
|
|
640
|
+
|
|
641
|
+
if path.is_symlink() or not path.is_file() or not stat.S_ISREG(path.stat().st_mode):
|
|
642
|
+
raise ValueError("wheel must be a regular file")
|
|
643
|
+
wheel_sha, size = _sha_file(path, MAX_ARCHIVE_BYTES)
|
|
644
|
+
with ZipFile(path) as archive:
|
|
645
|
+
members = _members(archive)
|
|
646
|
+
names = {item.filename for item in members}
|
|
647
|
+
metadata_paths = tuple(name for name in names if name.endswith(".dist-info/METADATA"))
|
|
648
|
+
if len(metadata_paths) != 1:
|
|
649
|
+
raise ValueError("wheel requires exactly one dist-info METADATA document")
|
|
650
|
+
metadata_path = metadata_paths[0]
|
|
651
|
+
prefix = metadata_path.removesuffix("METADATA")
|
|
652
|
+
if "/" in prefix.rstrip("/") or any(
|
|
653
|
+
prefix + file not in names for file in ("RECORD", "WHEEL")
|
|
654
|
+
):
|
|
655
|
+
raise ValueError("wheel requires a top-level dist-info directory and RECORD")
|
|
656
|
+
metadata_bytes = _read_member(archive, metadata_path)
|
|
657
|
+
wheel_bytes = _read_member(archive, prefix + "WHEEL")
|
|
658
|
+
metadata = _headers(metadata_bytes)
|
|
659
|
+
wheel = _headers(wheel_bytes)
|
|
660
|
+
metadata_version = _required(metadata, "Metadata-Version")
|
|
661
|
+
if metadata_version not in ("1.0", "1.1", "1.2", "2.1", "2.2", "2.3", "2.4", "2.5", "2.6"):
|
|
662
|
+
raise ValueError("unsupported distribution Metadata-Version")
|
|
663
|
+
wheel_version = _required(wheel, "Wheel-Version")
|
|
664
|
+
if wheel_version != "1.0":
|
|
665
|
+
raise ValueError("unsupported Wheel-Version")
|
|
666
|
+
name = _required(metadata, "Name")
|
|
667
|
+
canonical = _name(name)
|
|
668
|
+
version = str(Version(_required(metadata, "Version")))
|
|
669
|
+
directory_name, directory_version = prefix.removesuffix(".dist-info/").rsplit("-", 1)
|
|
670
|
+
if _name(directory_name) != canonical or str(Version(directory_version)) != version:
|
|
671
|
+
raise ValueError("dist-info directory disagrees with its name/version metadata")
|
|
672
|
+
pure = _required(wheel, "Root-Is-Purelib")
|
|
673
|
+
if pure not in ("true", "false"):
|
|
674
|
+
raise ValueError("Root-Is-Purelib must be true or false")
|
|
675
|
+
tags = tuple(sorted(str(value).strip() for value in wheel.get_all("Tag", [])))
|
|
676
|
+
if any(
|
|
677
|
+
len(tag.split("-")) != 3 or any(not part for part in tag.split("-")) for tag in tags
|
|
678
|
+
):
|
|
679
|
+
raise ValueError("wheel tags require python-ABI-platform triples")
|
|
680
|
+
filename_name, filename_version, _, filename_tags = parse_wheel_filename(path.name)
|
|
681
|
+
if (
|
|
682
|
+
filename_name != canonical
|
|
683
|
+
or str(filename_version) != version
|
|
684
|
+
or filename_tags != (frozenset(tag for text in tags for tag in parse_tag(text)))
|
|
685
|
+
):
|
|
686
|
+
raise ValueError("wheel filename disagrees with its name/version/platform metadata")
|
|
687
|
+
requires_python = _single(metadata, "Requires-Python", required=False)
|
|
688
|
+
license_expression = _single(metadata, "License-Expression", required=False)
|
|
689
|
+
license_text = _single(metadata, "License", required=False)
|
|
690
|
+
if license_expression is not None and license_text is not None:
|
|
691
|
+
raise ValueError("License and License-Expression are mutually exclusive")
|
|
692
|
+
license_files = tuple(sorted(str(v).strip() for v in metadata.get_all("License-File", [])))
|
|
693
|
+
for filename in license_files:
|
|
694
|
+
_relative(filename)
|
|
695
|
+
if prefix + "licenses/" + filename not in names and prefix + filename not in names:
|
|
696
|
+
raise ValueError("wheel declares a missing license file")
|
|
697
|
+
for filename in names:
|
|
698
|
+
if filename.endswith("/py.typed"):
|
|
699
|
+
_read_member(archive, filename)
|
|
700
|
+
urls: list[ProjectURL] = []
|
|
701
|
+
for value in metadata.get_all("Project-URL", []):
|
|
702
|
+
label, separator, url = str(value).partition(",")
|
|
703
|
+
if not separator:
|
|
704
|
+
raise ValueError("Project-URL metadata requires label and URL")
|
|
705
|
+
urls.append(ProjectURL(label=label.strip(), url=url.strip()))
|
|
706
|
+
result = WheelDistributionMetadata(
|
|
707
|
+
wheel_sha256=wheel_sha,
|
|
708
|
+
wheel_size_bytes=size,
|
|
709
|
+
metadata_sha256=hashlib.sha256(metadata_bytes).hexdigest(),
|
|
710
|
+
wheel_metadata_sha256=hashlib.sha256(wheel_bytes).hexdigest(),
|
|
711
|
+
metadata_version=metadata_version,
|
|
712
|
+
name=name,
|
|
713
|
+
canonical_name=canonical,
|
|
714
|
+
version=version,
|
|
715
|
+
requires_python=_spec(requires_python) if requires_python is not None else None,
|
|
716
|
+
dependencies=tuple(
|
|
717
|
+
sorted(_requirement(str(v)) for v in metadata.get_all("Requires-Dist", []))
|
|
718
|
+
),
|
|
719
|
+
extras=tuple(sorted(_name(str(v)) for v in metadata.get_all("Provides-Extra", []))),
|
|
720
|
+
classifiers=tuple(sorted(str(v).strip() for v in metadata.get_all("Classifier", []))),
|
|
721
|
+
urls=tuple(sorted(urls, key=lambda item: item.label)),
|
|
722
|
+
license_expression=license_expression,
|
|
723
|
+
license_text=license_text,
|
|
724
|
+
license_files=license_files,
|
|
725
|
+
typed_markers=tuple(sorted(name for name in names if name.endswith("/py.typed"))),
|
|
726
|
+
wheel_version="1.0",
|
|
727
|
+
root_is_purelib=pure == "true",
|
|
728
|
+
tags=tags,
|
|
729
|
+
member_count=len(members),
|
|
730
|
+
expanded_size_bytes=sum(item.file_size for item in members),
|
|
731
|
+
)
|
|
732
|
+
if _sha_file(path, MAX_ARCHIVE_BYTES) != (wheel_sha, size):
|
|
733
|
+
raise ValueError("wheel changed while being inventoried")
|
|
734
|
+
return result
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _check_wheel(project: ProjectDistributionMetadata, wheel: WheelDistributionMetadata) -> None:
|
|
738
|
+
expected = list(project.dependencies)
|
|
739
|
+
for optional in project.optional_dependencies:
|
|
740
|
+
expected.extend(
|
|
741
|
+
_optional_requirement(requirement, optional.extra)
|
|
742
|
+
for requirement in optional.requirements
|
|
743
|
+
)
|
|
744
|
+
if (
|
|
745
|
+
project.canonical_name != wheel.canonical_name
|
|
746
|
+
or project.version != wheel.version
|
|
747
|
+
or project.requires_python != wheel.requires_python
|
|
748
|
+
or tuple(sorted(expected)) != wheel.dependencies
|
|
749
|
+
or tuple(item.extra for item in project.optional_dependencies) != wheel.extras
|
|
750
|
+
or project.classifiers != wheel.classifiers
|
|
751
|
+
or project.urls != wheel.urls
|
|
752
|
+
):
|
|
753
|
+
raise ValueError(
|
|
754
|
+
"wheel metadata disagrees with project name/version/dependencies/Python/URLs"
|
|
755
|
+
)
|
|
756
|
+
markers = tuple(path.removeprefix("src/") for path in project.typed_markers)
|
|
757
|
+
if markers != wheel.typed_markers:
|
|
758
|
+
raise ValueError("wheel typing markers disagree with source")
|
|
759
|
+
if project.license_files != wheel.license_files:
|
|
760
|
+
raise ValueError("wheel license files disagree with source inventory")
|
|
761
|
+
license_metadata = project.license
|
|
762
|
+
expected_expression = license_metadata.expression if license_metadata is not None else None
|
|
763
|
+
if expected_expression != wheel.license_expression:
|
|
764
|
+
raise ValueError("wheel license expression disagrees with project")
|
|
765
|
+
if (license_metadata is None and wheel.license_text is not None) or (
|
|
766
|
+
license_metadata is not None
|
|
767
|
+
and license_metadata.text is not None
|
|
768
|
+
and license_metadata.text != wheel.license_text
|
|
769
|
+
):
|
|
770
|
+
raise ValueError("wheel license text disagrees with project")
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _check_pkg_info(project: ProjectDistributionMetadata, message: Message) -> None:
|
|
774
|
+
"""Bind passive sdist metadata to every inventoried declaration, without execution."""
|
|
775
|
+
from packaging.version import Version
|
|
776
|
+
|
|
777
|
+
if _required(message, "Metadata-Version") not in (
|
|
778
|
+
"1.0",
|
|
779
|
+
"1.1",
|
|
780
|
+
"1.2",
|
|
781
|
+
"2.1",
|
|
782
|
+
"2.2",
|
|
783
|
+
"2.3",
|
|
784
|
+
"2.4",
|
|
785
|
+
"2.5",
|
|
786
|
+
"2.6",
|
|
787
|
+
):
|
|
788
|
+
raise ValueError("unsupported sdist Metadata-Version")
|
|
789
|
+
for value in message.get_all("Dynamic", []):
|
|
790
|
+
field = str(value).strip().lower()
|
|
791
|
+
if re.fullmatch(r"[a-z][a-z0-9-]*", field) is None or field in {
|
|
792
|
+
"name",
|
|
793
|
+
"version",
|
|
794
|
+
"requires-dist",
|
|
795
|
+
"provides-extra",
|
|
796
|
+
"requires-python",
|
|
797
|
+
"classifier",
|
|
798
|
+
"project-url",
|
|
799
|
+
"license",
|
|
800
|
+
"license-expression",
|
|
801
|
+
"license-file",
|
|
802
|
+
}:
|
|
803
|
+
raise ValueError("sdist metadata retains an unresolved inventoried field")
|
|
804
|
+
expected = list(project.dependencies)
|
|
805
|
+
for optional in project.optional_dependencies:
|
|
806
|
+
expected.extend(
|
|
807
|
+
_optional_requirement(requirement, optional.extra)
|
|
808
|
+
for requirement in optional.requirements
|
|
809
|
+
)
|
|
810
|
+
urls = []
|
|
811
|
+
for value in message.get_all("Project-URL", []):
|
|
812
|
+
label, separator, url = str(value).partition(",")
|
|
813
|
+
if not separator:
|
|
814
|
+
raise ValueError("sdist Project-URL metadata requires label and URL")
|
|
815
|
+
urls.append(ProjectURL(label=label.strip(), url=url.strip()))
|
|
816
|
+
requires_python = _single(message, "Requires-Python", required=False)
|
|
817
|
+
if (
|
|
818
|
+
project.canonical_name != _name(_required(message, "Name"))
|
|
819
|
+
or project.version != str(Version(_required(message, "Version")))
|
|
820
|
+
or project.requires_python != (_spec(requires_python) if requires_python else None)
|
|
821
|
+
or tuple(sorted(expected))
|
|
822
|
+
!= tuple(sorted(_requirement(str(value)) for value in message.get_all("Requires-Dist", [])))
|
|
823
|
+
or tuple(item.extra for item in project.optional_dependencies)
|
|
824
|
+
!= tuple(sorted(_name(str(value)) for value in message.get_all("Provides-Extra", [])))
|
|
825
|
+
or project.classifiers
|
|
826
|
+
!= tuple(sorted(str(value).strip() for value in message.get_all("Classifier", [])))
|
|
827
|
+
or project.urls != tuple(sorted(urls, key=lambda item: item.label))
|
|
828
|
+
):
|
|
829
|
+
raise ValueError("sdist metadata disagrees with project declarations")
|
|
830
|
+
license_metadata = project.license
|
|
831
|
+
expression = license_metadata.expression if license_metadata is not None else None
|
|
832
|
+
license_text = _single(message, "License", required=False)
|
|
833
|
+
if (
|
|
834
|
+
expression != _single(message, "License-Expression", required=False)
|
|
835
|
+
or (expression is not None and license_text is not None)
|
|
836
|
+
or (license_metadata is None and license_text is not None)
|
|
837
|
+
or (
|
|
838
|
+
license_metadata is not None
|
|
839
|
+
and license_metadata.file is not None
|
|
840
|
+
and license_text is not None
|
|
841
|
+
)
|
|
842
|
+
or (
|
|
843
|
+
license_metadata is not None
|
|
844
|
+
and license_metadata.text is not None
|
|
845
|
+
and license_metadata.text != license_text
|
|
846
|
+
)
|
|
847
|
+
or project.license_files
|
|
848
|
+
!= tuple(sorted(str(value).strip() for value in message.get_all("License-File", [])))
|
|
849
|
+
):
|
|
850
|
+
raise ValueError("sdist license metadata disagrees with project")
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _pending(
|
|
854
|
+
project: ProjectDistributionMetadata,
|
|
855
|
+
files: dict[str, SourceFileEvidence],
|
|
856
|
+
wheel: WheelDistributionMetadata | None,
|
|
857
|
+
name_check: NameCheck,
|
|
858
|
+
) -> tuple[str, ...]:
|
|
859
|
+
pending: list[str] = []
|
|
860
|
+
readme = project.readme
|
|
861
|
+
if readme is None or (
|
|
862
|
+
readme.file is not None and (readme.file not in files or files[readme.file].size_bytes == 0)
|
|
863
|
+
):
|
|
864
|
+
pending.append("pending_readme")
|
|
865
|
+
if "uv.lock" not in files or files["uv.lock"].size_bytes == 0:
|
|
866
|
+
pending.append("pending_lock")
|
|
867
|
+
if project.requires_python is None:
|
|
868
|
+
pending.append("pending_requires_python")
|
|
869
|
+
if not project.typed_markers:
|
|
870
|
+
pending.append("pending_typing_marker")
|
|
871
|
+
if project.license is None or not any(files[name].size_bytes for name in project.license_files):
|
|
872
|
+
pending.append("pending_license")
|
|
873
|
+
if not name_check.controlled:
|
|
874
|
+
pending.append("pending_pypi_name_control")
|
|
875
|
+
if wheel is None:
|
|
876
|
+
pending.append("pending_wheel_metadata")
|
|
877
|
+
pending.extend(
|
|
878
|
+
("pending_build_install_verification", "pending_current_release_platform_matrix")
|
|
879
|
+
)
|
|
880
|
+
return tuple(pending)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def inspect_distribution(
|
|
884
|
+
package_directory: Path,
|
|
885
|
+
wheel_path: Path | None = None,
|
|
886
|
+
*,
|
|
887
|
+
name_check: NameCheck | None = None,
|
|
888
|
+
) -> DistributionReadiness:
|
|
889
|
+
"""Inventory local source/wheel without fetching, building or importing the package.
|
|
890
|
+
|
|
891
|
+
A dynamic version requires an identified wheel or an extracted sdist's
|
|
892
|
+
concordant PKG-INFO. Their passive metadata does not authenticate a release;
|
|
893
|
+
all existing build, platform and publication gates remain pending.
|
|
894
|
+
"""
|
|
895
|
+
if package_directory.is_symlink() or not package_directory.is_dir():
|
|
896
|
+
raise ValueError("package directory must be a real directory")
|
|
897
|
+
root = package_directory.resolve()
|
|
898
|
+
wheel = _wheel(wheel_path) if wheel_path is not None else None
|
|
899
|
+
project, files = _source(root, wheel)
|
|
900
|
+
observed = (
|
|
901
|
+
NameCheck.model_validate(name_check)
|
|
902
|
+
if name_check is not None
|
|
903
|
+
else NameCheck(
|
|
904
|
+
name=project.name,
|
|
905
|
+
query_url=f"https://pypi.org/pypi/{project.canonical_name}/json",
|
|
906
|
+
fetched_at=None,
|
|
907
|
+
http_status=None,
|
|
908
|
+
status="unknown",
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
inventory = {item.relative_path: item for item in files}
|
|
912
|
+
pending = _pending(project, inventory, wheel, observed)
|
|
913
|
+
return DistributionReadiness(
|
|
914
|
+
package_directory=str(root),
|
|
915
|
+
project=project,
|
|
916
|
+
source_files=files,
|
|
917
|
+
source_manifest_sha256=_inventory_digest(files),
|
|
918
|
+
lock_sha256=inventory["uv.lock"].sha256 if "uv.lock" in inventory else None,
|
|
919
|
+
wheel=wheel,
|
|
920
|
+
name_check=observed,
|
|
921
|
+
pending_conditions=pending,
|
|
922
|
+
wheel_metadata_verified=wheel is not None,
|
|
923
|
+
technical_inventory_complete=not any(
|
|
924
|
+
condition in pending
|
|
925
|
+
for condition in (
|
|
926
|
+
"pending_readme",
|
|
927
|
+
"pending_lock",
|
|
928
|
+
"pending_requires_python",
|
|
929
|
+
"pending_typing_marker",
|
|
930
|
+
)
|
|
931
|
+
),
|
|
932
|
+
)
|