Skip to content

Commit 81b3de7

Browse files
authored
Merge pull request #89 from python-project-templates/cargo-license-bundles
Add Cargo dependency license bundles
2 parents a22f633 + ff7ff52 commit 81b3de7

5 files changed

Lines changed: 585 additions & 4 deletions

File tree

‎.github/workflows/build.yaml‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ jobs:
4949
- name: Install dependencies
5050
run: make develop
5151

52+
- name: Install Cargo license collector
53+
run: cargo install cargo-bundle-licenses --version 4.2.0 --locked
54+
5255
- name: Lint
5356
run: make lint
5457

‎README.md‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,72 @@ destination = "project/lib/{shared_library}"
4747
Destination templates support `{module}`, `{target}`, `{profile}`, `{name}`,
4848
`{shared_library}`, `{import_library}`, and `{python_extension_name}`.
4949

50+
### Third-party Cargo licenses
51+
52+
Configure the `licenses` table to collect dependency license texts with
53+
[cargo-bundle-licenses](https://github.com/sstadick/cargo-bundle-licenses).
54+
Install the collector before building; hatch-rs does not install Cargo tools:
55+
56+
```sh
57+
cargo install cargo-bundle-licenses --version 4.2.0 --locked
58+
```
59+
60+
```toml
61+
[tool.hatch.build.hooks.hatch-rs]
62+
module = "project"
63+
path = "rust"
64+
target-dir = "isolated"
65+
66+
[tool.hatch.build.hooks.hatch-rs.licenses]
67+
destination = "{module}/licenses/{name}.json"
68+
```
69+
70+
The empty `licenses` table also enables collection with this destination.
71+
Without the table, builds do not run the collector. Each compiled artifact gets
72+
a JSON bundle containing dependency names, versions, license identifiers, and
73+
original license texts. `{name}` uses the artifact name, or `cargo` for the
74+
implicit Python extension artifact. Give multiple artifacts distinct destinations.
75+
Generated-file and skipped artifacts do not run the collector.
76+
77+
The hook runs the collector in the Cargo manifest directory and passes the
78+
artifact's enabled features and environment. For `all-features` builds, it reads
79+
the feature names from Cargo metadata. With licenses enabled, dependency-selection
80+
options such as `--features` and `--package` must use the structured hook or
81+
artifact fields rather than `cargo-args`. Bundles are copied into the Python package
82+
before temporary build directories are cleaned up, then forced into the wheel.
83+
They are also recorded when `artifact-manifest = true`. These package files do
84+
not add Python `License-File` metadata entries.
85+
86+
Missing tools, failed collector commands, invalid output, or missing license
87+
texts fail the build. A reviewed JSON bundle can supply texts the collector could
88+
not find:
89+
90+
```toml
91+
[tool.hatch.build.hooks.hatch-rs.licenses]
92+
previous = "third-party.json"
93+
check-previous = true
94+
```
95+
96+
`previous` is relative to the hook's `path`, unless absolute. `check-previous`
97+
also requires the newly collected bundle to be a subset of the reviewed bundle;
98+
omit it to reuse reviewed texts without checking for dependency changes. Include
99+
the reviewed bundle and Rust sources in the sdist when building wheels from it.
100+
Collection itself runs only for wheels, including editable wheels.
101+
102+
The hook resolves each selected Cargo package through metadata and runs the
103+
collector in that package's directory, so licenses of linked workspace members
104+
are included. A virtual workspace manifest requires an artifact `package`
105+
selector.
106+
107+
The collector's scope is conservative: version 4.2.0 always enables default
108+
features, cannot filter by target, and collects normal dependencies rather than
109+
build-only or dev-only dependencies. A bundle can include more crates than the
110+
artifact uses. The collector does not accept
111+
Cargo's `--locked` or `--frozen` flags, so prepare the dependency cache and review
112+
the resulting lockfile when reproducibility matters. Vendored native code,
113+
additional notice files, and libraries added during wheel repair need separate
114+
review. Collection does not establish license compliance.
115+
50116
### Using the Rust debug profile for editable installs
51117

52118
By default rust release profile is used to compile the artifacts and it can

‎hatch_rs/structs.py‎

Lines changed: 128 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from ctypes import CDLL
66
from dataclasses import dataclass
77
from glob import glob
8-
from json import dumps
8+
from json import dumps, loads
99
from os import environ
1010
from pathlib import Path
1111
from platform import machine as platform_machine
@@ -18,10 +18,11 @@
1818
from typing import Any, Literal
1919

2020
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
2222

2323
__all__ = (
2424
"CargoInvocation",
25+
"CargoLicenseConfig",
2526
"CopiedArtifact",
2627
"GeneratedOutputConfig",
2728
"HatchRustBuildConfig",
@@ -459,6 +460,24 @@ def _profile_output_dir(profile: str) -> str:
459460
return profile
460461

461462

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+
462481
class GeneratedOutputConfig(BaseModel):
463482
"""A generated file output and how it should be included in the wheel."""
464483

@@ -702,6 +721,7 @@ class HatchRustBuildConfig(BaseModel):
702721
target_dir: str | None = Field(default=None, alias="target-dir", description="Cargo target directory mode or explicit path.")
703722
env: dict[str, str] = Field(default_factory=dict, description="Additional environment variables for Cargo.")
704723
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.")
705725
artifact_manifest: bool = Field(
706726
default=False,
707727
alias="artifact-manifest",
@@ -936,7 +956,16 @@ def _build_artifact_plan(self, artifact: RustArtifactConfig, *, global_target: s
936956
build_command.append("--locked")
937957
if self._artifact_bool(artifact.frozen, self.frozen):
938958
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)
940969

941970
user_rustc_args = self._artifact_rustc_args(artifact)
942971
user_crate_types = []
@@ -1702,6 +1731,99 @@ def _copy_outputs(self, planned_artifact: PlannedArtifact, *, build_root: Path)
17021731
self._validate_artifact(planned_artifact, build_root=build_root, copied_artifacts=copied_artifacts)
17031732
return copied_artifacts
17041733

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+
17051827
def execute(self):
17061828
"""Execute the build commands."""
17071829
build_root = Path.cwd().resolve()
@@ -1738,6 +1860,9 @@ def execute(self):
17381860
raise RuntimeError(f"hatch-rs build command failed with exit code {completed.returncode}: {planned_artifact.invocation.display}")
17391861
self._copy_outputs(planned_artifact, build_root=build_root)
17401862

1863+
for planned_artifact in artifact_plans:
1864+
self._collect_licenses(planned_artifact, build_root=build_root)
1865+
17411866
self._write_artifact_manifest(build_root=build_root)
17421867

17431868
return self.commands

0 commit comments

Comments
 (0)