|
5 | 5 | from ctypes import CDLL |
6 | 6 | from dataclasses import dataclass |
7 | 7 | from glob import glob |
8 | | -from json import dumps |
| 8 | +from json import dumps, loads |
9 | 9 | from os import environ |
10 | 10 | from pathlib import Path |
11 | 11 | from platform import machine as platform_machine |
|
18 | 18 | from typing import Any, Literal |
19 | 19 |
|
20 | 20 | from packaging.tags import cpython_tags, mac_platforms, platform_tags |
21 | | -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator |
| 21 | +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator |
22 | 22 |
|
23 | 23 | __all__ = ( |
24 | 24 | "CargoInvocation", |
| 25 | + "CargoLicenseConfig", |
25 | 26 | "CopiedArtifact", |
26 | 27 | "GeneratedOutputConfig", |
27 | 28 | "HatchRustBuildConfig", |
@@ -459,6 +460,24 @@ def _profile_output_dir(profile: str) -> str: |
459 | 460 | return profile |
460 | 461 |
|
461 | 462 |
|
| 463 | +class CargoLicenseConfig(BaseModel): |
| 464 | + """Opt-in cargo-bundle-licenses collection for compiled Rust artifacts.""" |
| 465 | + |
| 466 | + model_config = ConfigDict(extra="ignore", populate_by_name=True) |
| 467 | + |
| 468 | + destination: str = Field(default="{module}/licenses/{name}.json", description="Wheel-relative destination template for each license bundle.") |
| 469 | + previous: Path | None = Field(default=None, description="Reviewed JSON bundle used to fill missing license text, relative to the hook path.") |
| 470 | + check_previous: bool = Field( |
| 471 | + default=False, alias="check-previous", description="Require the generated bundle to be a subset of the reviewed bundle." |
| 472 | + ) |
| 473 | + |
| 474 | + @model_validator(mode="after") |
| 475 | + def validate_previous(self) -> CargoLicenseConfig: |
| 476 | + if self.check_previous and self.previous is None: |
| 477 | + raise ValueError("licenses.check-previous requires licenses.previous.") |
| 478 | + return self |
| 479 | + |
| 480 | + |
462 | 481 | class GeneratedOutputConfig(BaseModel): |
463 | 482 | """A generated file output and how it should be included in the wheel.""" |
464 | 483 |
|
@@ -702,6 +721,7 @@ class HatchRustBuildConfig(BaseModel): |
702 | 721 | target_dir: str | None = Field(default=None, alias="target-dir", description="Cargo target directory mode or explicit path.") |
703 | 722 | env: dict[str, str] = Field(default_factory=dict, description="Additional environment variables for Cargo.") |
704 | 723 | artifacts: list[RustArtifactConfig] = Field(default_factory=list, description="Explicit Rust artifacts to build and package.") |
| 724 | + licenses: CargoLicenseConfig | None = Field(default=None, description="Collect and package Cargo dependency license texts when configured.") |
705 | 725 | artifact_manifest: bool = Field( |
706 | 726 | default=False, |
707 | 727 | alias="artifact-manifest", |
@@ -936,7 +956,16 @@ def _build_artifact_plan(self, artifact: RustArtifactConfig, *, global_target: s |
936 | 956 | build_command.append("--locked") |
937 | 957 | if self._artifact_bool(artifact.frozen, self.frozen): |
938 | 958 | build_command.append("--frozen") |
939 | | - build_command.extend(self._artifact_cargo_args(artifact)) |
| 959 | + cargo_args = self._artifact_cargo_args(artifact) |
| 960 | + if self.licenses is not None: |
| 961 | + selectors = {"--features", "--all-features", "--no-default-features", "--package", "--manifest-path", "--target", "-F", "-p"} |
| 962 | + if any( |
| 963 | + argument.split("=", 1)[0] in selectors |
| 964 | + or (argument.startswith("-") and not argument.startswith("--") and argument[1:].lstrip("vqrh").startswith(("F", "p"))) |
| 965 | + for argument in cargo_args |
| 966 | + ): |
| 967 | + raise ValueError("License collection requires dependency-selection options in cargo-args to use structured artifact fields instead.") |
| 968 | + build_command.extend(cargo_args) |
940 | 969 |
|
941 | 970 | user_rustc_args = self._artifact_rustc_args(artifact) |
942 | 971 | user_crate_types = [] |
@@ -1702,6 +1731,99 @@ def _copy_outputs(self, planned_artifact: PlannedArtifact, *, build_root: Path) |
1702 | 1731 | self._validate_artifact(planned_artifact, build_root=build_root, copied_artifacts=copied_artifacts) |
1703 | 1732 | return copied_artifacts |
1704 | 1733 |
|
| 1734 | + def _license_package(self, planned_artifact: PlannedArtifact, *, manifest: Path, environment: dict[str, str]) -> dict[str, Any]: |
| 1735 | + artifact = planned_artifact.artifact |
| 1736 | + command = ["cargo", "metadata", "--manifest-path", manifest.as_posix(), "--no-deps", "--format-version", "1"] |
| 1737 | + metadata = run(command, cwd=manifest.parent, env=environment, check=False, capture_output=True, text=True) |
| 1738 | + if metadata.returncode != 0: |
| 1739 | + raise RuntimeError(f"Artifact '{self._artifact_label(artifact)}' license package discovery failed: {metadata.stderr}") |
| 1740 | + packages = loads(metadata.stdout)["packages"] |
| 1741 | + if artifact.package: |
| 1742 | + command = ["cargo", "pkgid", "--manifest-path", manifest.as_posix(), "--package", artifact.package] |
| 1743 | + package_id = run(command, cwd=manifest.parent, env=environment, check=False, capture_output=True, text=True) |
| 1744 | + if package_id.returncode != 0: |
| 1745 | + raise RuntimeError(f"Artifact '{self._artifact_label(artifact)}' license package selection failed: {package_id.stderr}") |
| 1746 | + return next(package for package in packages if package["id"] == package_id.stdout.strip()) |
| 1747 | + for package in packages: |
| 1748 | + if Path(package["manifest_path"]).resolve() == manifest: |
| 1749 | + return package |
| 1750 | + raise ValueError(f"Artifact '{self._artifact_label(artifact)}' requires a Cargo package selector for workspace license collection.") |
| 1751 | + |
| 1752 | + def _collect_licenses(self, planned_artifact: PlannedArtifact, *, build_root: Path) -> None: |
| 1753 | + config = self.licenses |
| 1754 | + artifact = planned_artifact.artifact |
| 1755 | + if config is None or self._is_generated_artifact(artifact): |
| 1756 | + return |
| 1757 | + |
| 1758 | + label = self._artifact_label(artifact) |
| 1759 | + manifest = _resolve_path(self._artifact_manifest(artifact) or "Cargo.toml", base=Path(self.path)) |
| 1760 | + assert planned_artifact.invocation is not None |
| 1761 | + environment = planned_artifact.invocation.env |
| 1762 | + package = self._license_package(planned_artifact, manifest=manifest, environment=environment) |
| 1763 | + manifest = Path(package["manifest_path"]) |
| 1764 | + features = self._artifact_features(artifact) |
| 1765 | + if artifact.package: |
| 1766 | + features = [feature if "/" in feature else f"{package['name']}/{feature}" for feature in features] |
| 1767 | + if self._artifact_bool(artifact.all_features, self.all_features): |
| 1768 | + features.extend(f"{package['name']}/{feature}" for feature in package["features"]) |
| 1769 | + |
| 1770 | + with TemporaryDirectory(prefix="hatch-rs-licenses-") as directory: |
| 1771 | + output = Path(directory) / "cargo.json" |
| 1772 | + template = config.destination.replace("{name}", artifact.name or "cargo") |
| 1773 | + destination = self._format_destination(template, planned_artifact=planned_artifact, source=output) |
| 1774 | + resolved_destination = (build_root / destination).resolve() |
| 1775 | + if not config.destination or not resolved_destination.is_relative_to(build_root) or resolved_destination.is_dir(): |
| 1776 | + raise ValueError(f"Artifact '{label}' license destination must be a file within the build root: {config.destination}") |
| 1777 | + manifest_destination = (build_root / self._format_artifact_manifest_destination()).resolve() if self.artifact_manifest else None |
| 1778 | + if resolved_destination == manifest_destination or any( |
| 1779 | + copied.destination.resolve() == resolved_destination for copied in self._copied_artifacts |
| 1780 | + ): |
| 1781 | + raise ValueError(f"Artifact '{label}' license destination conflicts with a packaged artifact: {destination}") |
| 1782 | + |
| 1783 | + command = ["cargo-bundle-licenses", "--format", "json", "--output", output.as_posix()] |
| 1784 | + if features: |
| 1785 | + command.extend(("--features", ",".join(sorted(set(features))))) |
| 1786 | + if config.previous is not None: |
| 1787 | + command.extend(("--previous", _resolve_path(config.previous, base=Path(self.path)).as_posix())) |
| 1788 | + if config.check_previous: |
| 1789 | + command.append("--check-previous") |
| 1790 | + try: |
| 1791 | + completed = run(command, cwd=manifest.parent, env=environment, check=False) |
| 1792 | + except FileNotFoundError as error: |
| 1793 | + raise RuntimeError(f"Artifact '{label}' license collection requires cargo-bundle-licenses. Install it before building.") from error |
| 1794 | + if completed.returncode != 0: |
| 1795 | + raise RuntimeError(f"Artifact '{label}' license collection failed with exit code {completed.returncode}: {shell_join(command)}") |
| 1796 | + |
| 1797 | + try: |
| 1798 | + bundle = loads(output.read_text(encoding="utf-8")) |
| 1799 | + libraries = bundle["third_party_libraries"] |
| 1800 | + if not isinstance(bundle["root_name"], str) or not isinstance(libraries, list): |
| 1801 | + raise TypeError("Expected a root_name string and a third_party_libraries list.") |
| 1802 | + for library in libraries: |
| 1803 | + texts = library["licenses"] |
| 1804 | + if not texts or any(not license["text"].strip() or license["text"].strip() == "NOT FOUND" for license in texts): |
| 1805 | + raise RuntimeError( |
| 1806 | + f"Dependency '{library['package_name']}' is missing license text. Supply a reviewed licenses.previous bundle." |
| 1807 | + ) |
| 1808 | + if config.check_previous: |
| 1809 | + assert config.previous is not None |
| 1810 | + previous = loads(_resolve_path(config.previous, base=Path(self.path)).read_text(encoding="utf-8")) |
| 1811 | + |
| 1812 | + def entries(value: dict[str, Any]) -> set[tuple[str, ...]]: |
| 1813 | + return { |
| 1814 | + (library["package_name"], library["package_version"], library["license"], license["license"], license["text"]) |
| 1815 | + for library in value["third_party_libraries"] |
| 1816 | + for license in library["licenses"] |
| 1817 | + } |
| 1818 | + |
| 1819 | + if bundle["root_name"] != previous["root_name"] or not entries(bundle).issubset(entries(previous)): |
| 1820 | + raise RuntimeError( |
| 1821 | + f"Artifact '{label}' license bundle contains changes not present in the reviewed licenses.previous bundle." |
| 1822 | + ) |
| 1823 | + except (OSError, ValueError, KeyError, TypeError, AttributeError) as error: |
| 1824 | + raise RuntimeError(f"Artifact '{label}' license collector did not produce a valid JSON license bundle: {error}") from error |
| 1825 | + self._copy_artifact(output, destination, build_root=build_root, planned_artifact=planned_artifact, role="third-party-licenses") |
| 1826 | + |
1705 | 1827 | def execute(self): |
1706 | 1828 | """Execute the build commands.""" |
1707 | 1829 | build_root = Path.cwd().resolve() |
@@ -1738,6 +1860,9 @@ def execute(self): |
1738 | 1860 | raise RuntimeError(f"hatch-rs build command failed with exit code {completed.returncode}: {planned_artifact.invocation.display}") |
1739 | 1861 | self._copy_outputs(planned_artifact, build_root=build_root) |
1740 | 1862 |
|
| 1863 | + for planned_artifact in artifact_plans: |
| 1864 | + self._collect_licenses(planned_artifact, build_root=build_root) |
| 1865 | + |
1741 | 1866 | self._write_artifact_manifest(build_root=build_root) |
1742 | 1867 |
|
1743 | 1868 | return self.commands |
|
0 commit comments