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.
Files changed (62) hide show
  1. equiframe_benchmark/PROTOCOL_V2.md +172 -0
  2. equiframe_benchmark/__init__.py +284 -0
  3. equiframe_benchmark/__main__.py +3 -0
  4. equiframe_benchmark/_scale_workload.py +375 -0
  5. equiframe_benchmark/calibration_types.py +112 -0
  6. equiframe_benchmark/cli.py +196 -0
  7. equiframe_benchmark/commands.py +149 -0
  8. equiframe_benchmark/commands_scale.py +38 -0
  9. equiframe_benchmark/commands_step10.py +275 -0
  10. equiframe_benchmark/commands_v2.py +206 -0
  11. equiframe_benchmark/cost_table.py +62 -0
  12. equiframe_benchmark/costs.py +788 -0
  13. equiframe_benchmark/distribution.py +932 -0
  14. equiframe_benchmark/evaluation.py +614 -0
  15. equiframe_benchmark/evaluation_v2.py +428 -0
  16. equiframe_benchmark/fixtures.py +299 -0
  17. equiframe_benchmark/freeze.py +869 -0
  18. equiframe_benchmark/freeze_v2.py +412 -0
  19. equiframe_benchmark/integration.py +655 -0
  20. equiframe_benchmark/io.py +29 -0
  21. equiframe_benchmark/models.py +313 -0
  22. equiframe_benchmark/models_v2.py +13 -0
  23. equiframe_benchmark/mutations.py +34 -0
  24. equiframe_benchmark/oracle.py +264 -0
  25. equiframe_benchmark/pairwise.py +248 -0
  26. equiframe_benchmark/pilot.py +254 -0
  27. equiframe_benchmark/py.typed +0 -0
  28. equiframe_benchmark/reconciliation.py +294 -0
  29. equiframe_benchmark/reproduction.py +542 -0
  30. equiframe_benchmark/scale_io.py +19 -0
  31. equiframe_benchmark/scale_process.py +1077 -0
  32. equiframe_benchmark/scale_types.py +120 -0
  33. equiframe_benchmark/scaling.py +529 -0
  34. equiframe_benchmark/schemas/protocol-v2/CommonSupportV2-2.0.schema.json +327 -0
  35. equiframe_benchmark/schemas/protocol-v2/ContrastSpecV2-2.0.schema.json +37 -0
  36. equiframe_benchmark/schemas/protocol-v2/EvaluationConfigV2-2.0.schema.json +184 -0
  37. equiframe_benchmark/schemas/protocol-v2/EvaluationReportV2-2.0.schema.json +1237 -0
  38. equiframe_benchmark/schemas/protocol-v2/EvaluationStratumV2-2.0.schema.json +747 -0
  39. equiframe_benchmark/schemas/protocol-v2/FreezeDefinitionV2-2.0.schema.json +5951 -0
  40. equiframe_benchmark/schemas/protocol-v2/FreezeManifestV2-2.0.schema.json +6222 -0
  41. equiframe_benchmark/schemas/protocol-v2/PrincipalContrastV2-2.0.schema.json +578 -0
  42. equiframe_benchmark/schemas/protocol-v2/ProtocolReviewV2-2.0.schema.json +56 -0
  43. equiframe_benchmark/schemas/protocol-v2/SignedMetricV2-2.0.schema.json +51 -0
  44. equiframe_benchmark/schemas/protocol-v2/StudyCaseResultV2-2.0.schema.json +4881 -0
  45. equiframe_benchmark/schemas/protocol-v2/StudyResultV2-2.0.schema.json +8370 -0
  46. equiframe_benchmark/schemas/protocol-v2/TableExportV2-2.0.schema.json +75 -0
  47. equiframe_benchmark/selection.py +178 -0
  48. equiframe_benchmark/snapshot.py +24 -0
  49. equiframe_benchmark/splits.py +92 -0
  50. equiframe_benchmark/study.py +526 -0
  51. equiframe_benchmark/study_bundle.py +379 -0
  52. equiframe_benchmark/study_bundle_v2.py +388 -0
  53. equiframe_benchmark/study_v2.py +544 -0
  54. equiframe_benchmark/tables.py +347 -0
  55. equiframe_benchmark/tables_v2.py +340 -0
  56. equiframe_benchmark/training_types.py +52 -0
  57. equiframe_benchmark-0.12.4.dist-info/METADATA +343 -0
  58. equiframe_benchmark-0.12.4.dist-info/RECORD +62 -0
  59. equiframe_benchmark-0.12.4.dist-info/WHEEL +4 -0
  60. equiframe_benchmark-0.12.4.dist-info/entry_points.txt +3 -0
  61. equiframe_benchmark-0.12.4.dist-info/licenses/LICENSE +21 -0
  62. 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
+ )