diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 2cba429..41d718d 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -49,6 +49,9 @@ jobs: - name: Install dependencies run: make develop + - name: Install Cargo license collector + run: cargo install cargo-bundle-licenses --version 4.2.0 --locked + - name: Lint run: make lint diff --git a/README.md b/README.md index 9b8a425..f7f7bed 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,72 @@ destination = "project/lib/{shared_library}" Destination templates support `{module}`, `{target}`, `{profile}`, `{name}`, `{shared_library}`, `{import_library}`, and `{python_extension_name}`. +### Third-party Cargo licenses + +Configure the `licenses` table to collect dependency license texts with +[cargo-bundle-licenses](https://github.com/sstadick/cargo-bundle-licenses). +Install the collector before building; hatch-rs does not install Cargo tools: + +```sh +cargo install cargo-bundle-licenses --version 4.2.0 --locked +``` + +```toml +[tool.hatch.build.hooks.hatch-rs] +module = "project" +path = "rust" +target-dir = "isolated" + +[tool.hatch.build.hooks.hatch-rs.licenses] +destination = "{module}/licenses/{name}.json" +``` + +The empty `licenses` table also enables collection with this destination. +Without the table, builds do not run the collector. Each compiled artifact gets +a JSON bundle containing dependency names, versions, license identifiers, and +original license texts. `{name}` uses the artifact name, or `cargo` for the +implicit Python extension artifact. Give multiple artifacts distinct destinations. +Generated-file and skipped artifacts do not run the collector. + +The hook runs the collector in the Cargo manifest directory and passes the +artifact's enabled features and environment. For `all-features` builds, it reads +the feature names from Cargo metadata. With licenses enabled, dependency-selection +options such as `--features` and `--package` must use the structured hook or +artifact fields rather than `cargo-args`. Bundles are copied into the Python package +before temporary build directories are cleaned up, then forced into the wheel. +They are also recorded when `artifact-manifest = true`. These package files do +not add Python `License-File` metadata entries. + +Missing tools, failed collector commands, invalid output, or missing license +texts fail the build. A reviewed JSON bundle can supply texts the collector could +not find: + +```toml +[tool.hatch.build.hooks.hatch-rs.licenses] +previous = "third-party.json" +check-previous = true +``` + +`previous` is relative to the hook's `path`, unless absolute. `check-previous` +also requires the newly collected bundle to be a subset of the reviewed bundle; +omit it to reuse reviewed texts without checking for dependency changes. Include +the reviewed bundle and Rust sources in the sdist when building wheels from it. +Collection itself runs only for wheels, including editable wheels. + +The hook resolves each selected Cargo package through metadata and runs the +collector in that package's directory, so licenses of linked workspace members +are included. A virtual workspace manifest requires an artifact `package` +selector. + +The collector's scope is conservative: version 4.2.0 always enables default +features, cannot filter by target, and collects normal dependencies rather than +build-only or dev-only dependencies. A bundle can include more crates than the +artifact uses. The collector does not accept +Cargo's `--locked` or `--frozen` flags, so prepare the dependency cache and review +the resulting lockfile when reproducibility matters. Vendored native code, +additional notice files, and libraries added during wheel repair need separate +review. Collection does not establish license compliance. + ### Using the Rust debug profile for editable installs By default rust release profile is used to compile the artifacts and it can diff --git a/hatch_rs/structs.py b/hatch_rs/structs.py index d71fab3..e5bfb48 100644 --- a/hatch_rs/structs.py +++ b/hatch_rs/structs.py @@ -5,7 +5,7 @@ from ctypes import CDLL from dataclasses import dataclass from glob import glob -from json import dumps +from json import dumps, loads from os import environ from pathlib import Path from platform import machine as platform_machine @@ -18,10 +18,11 @@ from typing import Any, Literal from packaging.tags import cpython_tags, mac_platforms, platform_tags -from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator +from pydantic import BaseModel, ConfigDict, Field, PrivateAttr, field_validator, model_validator __all__ = ( "CargoInvocation", + "CargoLicenseConfig", "CopiedArtifact", "GeneratedOutputConfig", "HatchRustBuildConfig", @@ -459,6 +460,24 @@ def _profile_output_dir(profile: str) -> str: return profile +class CargoLicenseConfig(BaseModel): + """Opt-in cargo-bundle-licenses collection for compiled Rust artifacts.""" + + model_config = ConfigDict(extra="ignore", populate_by_name=True) + + destination: str = Field(default="{module}/licenses/{name}.json", description="Wheel-relative destination template for each license bundle.") + previous: Path | None = Field(default=None, description="Reviewed JSON bundle used to fill missing license text, relative to the hook path.") + check_previous: bool = Field( + default=False, alias="check-previous", description="Require the generated bundle to be a subset of the reviewed bundle." + ) + + @model_validator(mode="after") + def validate_previous(self) -> CargoLicenseConfig: + if self.check_previous and self.previous is None: + raise ValueError("licenses.check-previous requires licenses.previous.") + return self + + class GeneratedOutputConfig(BaseModel): """A generated file output and how it should be included in the wheel.""" @@ -702,6 +721,7 @@ class HatchRustBuildConfig(BaseModel): target_dir: str | None = Field(default=None, alias="target-dir", description="Cargo target directory mode or explicit path.") env: dict[str, str] = Field(default_factory=dict, description="Additional environment variables for Cargo.") artifacts: list[RustArtifactConfig] = Field(default_factory=list, description="Explicit Rust artifacts to build and package.") + licenses: CargoLicenseConfig | None = Field(default=None, description="Collect and package Cargo dependency license texts when configured.") artifact_manifest: bool = Field( default=False, alias="artifact-manifest", @@ -936,7 +956,16 @@ def _build_artifact_plan(self, artifact: RustArtifactConfig, *, global_target: s build_command.append("--locked") if self._artifact_bool(artifact.frozen, self.frozen): build_command.append("--frozen") - build_command.extend(self._artifact_cargo_args(artifact)) + cargo_args = self._artifact_cargo_args(artifact) + if self.licenses is not None: + selectors = {"--features", "--all-features", "--no-default-features", "--package", "--manifest-path", "--target", "-F", "-p"} + if any( + argument.split("=", 1)[0] in selectors + or (argument.startswith("-") and not argument.startswith("--") and argument[1:].lstrip("vqrh").startswith(("F", "p"))) + for argument in cargo_args + ): + raise ValueError("License collection requires dependency-selection options in cargo-args to use structured artifact fields instead.") + build_command.extend(cargo_args) user_rustc_args = self._artifact_rustc_args(artifact) user_crate_types = [] @@ -1702,6 +1731,99 @@ def _copy_outputs(self, planned_artifact: PlannedArtifact, *, build_root: Path) self._validate_artifact(planned_artifact, build_root=build_root, copied_artifacts=copied_artifacts) return copied_artifacts + def _license_package(self, planned_artifact: PlannedArtifact, *, manifest: Path, environment: dict[str, str]) -> dict[str, Any]: + artifact = planned_artifact.artifact + command = ["cargo", "metadata", "--manifest-path", manifest.as_posix(), "--no-deps", "--format-version", "1"] + metadata = run(command, cwd=manifest.parent, env=environment, check=False, capture_output=True, text=True) + if metadata.returncode != 0: + raise RuntimeError(f"Artifact '{self._artifact_label(artifact)}' license package discovery failed: {metadata.stderr}") + packages = loads(metadata.stdout)["packages"] + if artifact.package: + command = ["cargo", "pkgid", "--manifest-path", manifest.as_posix(), "--package", artifact.package] + package_id = run(command, cwd=manifest.parent, env=environment, check=False, capture_output=True, text=True) + if package_id.returncode != 0: + raise RuntimeError(f"Artifact '{self._artifact_label(artifact)}' license package selection failed: {package_id.stderr}") + return next(package for package in packages if package["id"] == package_id.stdout.strip()) + for package in packages: + if Path(package["manifest_path"]).resolve() == manifest: + return package + raise ValueError(f"Artifact '{self._artifact_label(artifact)}' requires a Cargo package selector for workspace license collection.") + + def _collect_licenses(self, planned_artifact: PlannedArtifact, *, build_root: Path) -> None: + config = self.licenses + artifact = planned_artifact.artifact + if config is None or self._is_generated_artifact(artifact): + return + + label = self._artifact_label(artifact) + manifest = _resolve_path(self._artifact_manifest(artifact) or "Cargo.toml", base=Path(self.path)) + assert planned_artifact.invocation is not None + environment = planned_artifact.invocation.env + package = self._license_package(planned_artifact, manifest=manifest, environment=environment) + manifest = Path(package["manifest_path"]) + features = self._artifact_features(artifact) + if artifact.package: + features = [feature if "/" in feature else f"{package['name']}/{feature}" for feature in features] + if self._artifact_bool(artifact.all_features, self.all_features): + features.extend(f"{package['name']}/{feature}" for feature in package["features"]) + + with TemporaryDirectory(prefix="hatch-rs-licenses-") as directory: + output = Path(directory) / "cargo.json" + template = config.destination.replace("{name}", artifact.name or "cargo") + destination = self._format_destination(template, planned_artifact=planned_artifact, source=output) + resolved_destination = (build_root / destination).resolve() + if not config.destination or not resolved_destination.is_relative_to(build_root) or resolved_destination.is_dir(): + raise ValueError(f"Artifact '{label}' license destination must be a file within the build root: {config.destination}") + manifest_destination = (build_root / self._format_artifact_manifest_destination()).resolve() if self.artifact_manifest else None + if resolved_destination == manifest_destination or any( + copied.destination.resolve() == resolved_destination for copied in self._copied_artifacts + ): + raise ValueError(f"Artifact '{label}' license destination conflicts with a packaged artifact: {destination}") + + command = ["cargo-bundle-licenses", "--format", "json", "--output", output.as_posix()] + if features: + command.extend(("--features", ",".join(sorted(set(features))))) + if config.previous is not None: + command.extend(("--previous", _resolve_path(config.previous, base=Path(self.path)).as_posix())) + if config.check_previous: + command.append("--check-previous") + try: + completed = run(command, cwd=manifest.parent, env=environment, check=False) + except FileNotFoundError as error: + raise RuntimeError(f"Artifact '{label}' license collection requires cargo-bundle-licenses. Install it before building.") from error + if completed.returncode != 0: + raise RuntimeError(f"Artifact '{label}' license collection failed with exit code {completed.returncode}: {shell_join(command)}") + + try: + bundle = loads(output.read_text(encoding="utf-8")) + libraries = bundle["third_party_libraries"] + if not isinstance(bundle["root_name"], str) or not isinstance(libraries, list): + raise TypeError("Expected a root_name string and a third_party_libraries list.") + for library in libraries: + texts = library["licenses"] + if not texts or any(not license["text"].strip() or license["text"].strip() == "NOT FOUND" for license in texts): + raise RuntimeError( + f"Dependency '{library['package_name']}' is missing license text. Supply a reviewed licenses.previous bundle." + ) + if config.check_previous: + assert config.previous is not None + previous = loads(_resolve_path(config.previous, base=Path(self.path)).read_text(encoding="utf-8")) + + def entries(value: dict[str, Any]) -> set[tuple[str, ...]]: + return { + (library["package_name"], library["package_version"], library["license"], license["license"], license["text"]) + for library in value["third_party_libraries"] + for license in library["licenses"] + } + + if bundle["root_name"] != previous["root_name"] or not entries(bundle).issubset(entries(previous)): + raise RuntimeError( + f"Artifact '{label}' license bundle contains changes not present in the reviewed licenses.previous bundle." + ) + except (OSError, ValueError, KeyError, TypeError, AttributeError) as error: + raise RuntimeError(f"Artifact '{label}' license collector did not produce a valid JSON license bundle: {error}") from error + self._copy_artifact(output, destination, build_root=build_root, planned_artifact=planned_artifact, role="third-party-licenses") + def execute(self): """Execute the build commands.""" build_root = Path.cwd().resolve() @@ -1738,6 +1860,9 @@ def execute(self): raise RuntimeError(f"hatch-rs build command failed with exit code {completed.returncode}: {planned_artifact.invocation.display}") self._copy_outputs(planned_artifact, build_root=build_root) + for planned_artifact in artifact_plans: + self._collect_licenses(planned_artifact, build_root=build_root) + self._write_artifact_manifest(build_root=build_root) return self.commands diff --git a/hatch_rs/tests/test_licenses.py b/hatch_rs/tests/test_licenses.py new file mode 100644 index 0000000..9cd05fa --- /dev/null +++ b/hatch_rs/tests/test_licenses.py @@ -0,0 +1,332 @@ +from __future__ import annotations + +from json import dumps, loads +from pathlib import Path +from subprocess import CompletedProcess +from typing import Any + +import pytest +from hatchling.metadata.core import ProjectMetadata + +from hatch_rs import structs +from hatch_rs.plugin import HatchRustBuildHook +from hatch_rs.structs import HatchRustBuildPlan + +BUNDLE: dict[str, Any] = { + "root_name": "project", + "third_party_libraries": [ + { + "package_name": "dependency", + "package_version": "1.0.0", + "repository": "https://example.com/dependency", + "license": "MIT", + "licenses": [{"license": "MIT", "text": "License text including the author's copyright notice\n"}], + } + ], +} + + +@pytest.fixture +def cargo_runner(monkeypatch, tmp_path): + calls = [] + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SKIP_HATCH_RUST", raising=False) + monkeypatch.delenv("CARGO_TARGET_DIR", raising=False) + + def run(args, *, cwd, env, check, **kwargs): + calls.append((tuple(args), Path(cwd), {key: env[key] for key in ("CARGO_TARGET_DIR", "COLLECTOR_CONTEXT") if key in env})) + if args[:2] == ("cargo", "rustc") or args[:2] == ["cargo", "rustc"]: + target = args[args.index("--target") + 1] + target_dir = Path(env.get("CARGO_TARGET_DIR", Path(cwd) / "target")) + artifact = "project" + if "--package" in args: + artifact = args[args.index("--package") + 1] + output = target_dir / target / "release" / f"lib{artifact}.so" + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(b"compiled library") + elif args[:2] == ["cargo", "metadata"]: + return CompletedProcess( + args, + 0, + stdout=dumps( + { + "packages": [ + { + "name": name, + "id": name, + "manifest_path": str(Path(cwd) / "Cargo.toml"), + "features": {"default": [], "extra": []}, + } + for name in ("project", "second") + ] + } + ), + ) + elif args[:2] == ["cargo", "pkgid"]: + return CompletedProcess(args, 0, stdout=args[args.index("--package") + 1] + "\n") + elif args[0] == "cargo-bundle-licenses": + output = Path(args[args.index("--output") + 1]) + output.write_text(dumps(BUNDLE), encoding="utf-8") + else: + raise AssertionError(f"Unexpected command: {args}") + return CompletedProcess(args, 0) + + monkeypatch.setattr("hatch_rs.structs.run", run) + return calls + + +def make_plan(tmp_path, **config): + rust = tmp_path / "rust project" + rust.mkdir(exist_ok=True) + (rust / "Cargo.toml").write_text('[package]\nname = "project"\nversion = "0.1.0"\n') + options: dict[str, Any] = {"target-dir": "isolated", **config} + return HatchRustBuildPlan( + module="project", + path=rust, + target="x86_64-unknown-linux-gnu", + **options, + ) + + +def test_license_collection_is_opt_in(tmp_path, cargo_runner): + plan = make_plan(tmp_path) + plan.execute() + plan.cleanup() + + assert len(cargo_runner) == 1 + assert (tmp_path / "project/project.so").read_bytes() == b"compiled library" + assert not (tmp_path / "project/licenses").exists() + + +def test_license_bundle_survives_cleanup_and_is_registered_with_hook(tmp_path, cargo_runner): + rust = tmp_path / "rust project" + rust.mkdir() + (rust / "Cargo.toml").write_text('[package]\nname = "project"\nversion = "0.1.0"\n') + hook = HatchRustBuildHook( + root=str(tmp_path), + config={"module": "project", "path": str(rust), "target": "x86_64-unknown-linux-gnu", "target-dir": "isolated", "licenses": {}}, + build_config=None, + metadata=ProjectMetadata(str(tmp_path), None, {"project": {"name": "project", "version": "0.1.0"}}), + directory=str(tmp_path), + target_name="wheel", + ) + build_data = {} + + hook.initialize("standard", build_data) + + assert loads((tmp_path / "project/licenses/cargo.json").read_text()) == BUNDLE + assert build_data["force_include"]["project/licenses/cargo.json"] == "project/licenses/cargo.json" + collector = cargo_runner[-1] + assert collector[0][:3] == ("cargo-bundle-licenses", "--format", "json") + assert collector[1] == rust + assert not Path(collector[0][collector[0].index("--output") + 1]).exists() + + +def test_license_collection_uses_manifest_directory_and_artifact_features(tmp_path, cargo_runner): + previous = tmp_path / "reviewed.json" + previous.write_text(dumps(BUNDLE)) + plan = make_plan( + tmp_path, + manifest="Cargo.toml", + features=["ignored-global-feature"], + env={"COLLECTOR_CONTEXT": "global"}, + licenses={"previous": "../reviewed.json", "check-previous": True}, + artifacts=[ + { + "name": "project", + "package": "project", + "destination": "project/lib/{shared_library}", + "features": ["extra"], + "env": {"COLLECTOR_CONTEXT": "artifact"}, + } + ], + ) + plan.execute() + plan.cleanup() + + args, cwd, env = cargo_runner[-1] + assert cwd == tmp_path / "rust project" + assert args[args.index("--features") + 1] == "project/extra" + assert args[args.index("--previous") + 1] == previous.as_posix() + assert "--check-previous" in args + assert env["COLLECTOR_CONTEXT"] == "artifact" + assert loads((tmp_path / "project/licenses/project.json").read_text()) == BUNDLE + + +def test_all_features_are_expanded_for_the_license_collector(tmp_path, cargo_runner): + plan = make_plan(tmp_path, **{"all-features": True, "licenses": {}}) + plan.execute() + plan.cleanup() + + args = cargo_runner[-1][0] + assert set(args[args.index("--features") + 1].split(",")) == {"project/default", "project/extra"} + assert any(call[0][:2] == ("cargo", "metadata") for call in cargo_runner) + + +def test_separate_license_bundles_for_multiple_artifacts(tmp_path, cargo_runner): + plan = make_plan( + tmp_path, + licenses={}, + artifacts=[{"name": name, "package": name, "destination": "project/lib/{shared_library}"} for name in ("project", "second")], + ) + plan.execute() + plan.cleanup() + + assert loads((tmp_path / "project/licenses/project.json").read_text()) == BUNDLE + assert loads((tmp_path / "project/licenses/second.json").read_text()) == BUNDLE + assert len([call for call in cargo_runner if call[0][0] == "cargo-bundle-licenses"]) == 2 + + +def test_generated_and_skipped_artifacts_do_not_collect_licenses(tmp_path, cargo_runner, monkeypatch): + monkeypatch.setenv("SKIP_SECOND", "1") + rust = tmp_path / "rust project" + rust.mkdir() + (rust / "generated.txt").write_text("generated\n") + plan = make_plan( + tmp_path, + licenses={}, + artifacts=[ + {"name": "header", "outputs": [{"source": "generated.txt", "destination": "project/header.txt"}]}, + {"name": "second", "skip-if-env": "SKIP_SECOND"}, + ], + ) + plan.execute() + plan.cleanup() + + assert cargo_runner == [] + assert (tmp_path / "project/header.txt").read_text() == "generated\n" + + +@pytest.mark.parametrize("text", ["NOT FOUND", "", " "]) +def test_missing_license_text_fails_the_build(tmp_path, cargo_runner, monkeypatch, text): + monkeypatch.setitem(BUNDLE["third_party_libraries"][0]["licenses"][0], "text", text) + plan = make_plan(tmp_path, licenses={}) + + try: + with pytest.raises(RuntimeError, match="dependency.*license text"): + plan.execute() + finally: + plan.cleanup() + assert not (tmp_path / "project/licenses/cargo.json").exists() + + +@pytest.mark.parametrize("failure", ["missing-tool", "command-failed", "missing-output", "invalid-json"]) +def test_collector_failures_are_not_silently_packaged(tmp_path, cargo_runner, monkeypatch, failure): + original_run = structs.run + + def failing_run(args, **kwargs): + if args[0] != "cargo-bundle-licenses": + return original_run(args, **kwargs) + if failure == "missing-tool": + raise FileNotFoundError("cargo-bundle-licenses") + if failure == "invalid-json": + Path(args[args.index("--output") + 1]).write_text("not JSON") + return CompletedProcess(args, 1 if failure == "command-failed" else 0) + + monkeypatch.setattr("hatch_rs.structs.run", failing_run) + plan = make_plan(tmp_path, licenses={}) + + try: + with pytest.raises(RuntimeError, match="license|License"): + plan.execute() + finally: + plan.cleanup() + + +@pytest.mark.parametrize("destination", ["../outside.json", "/outside.json", "", "project/project.so"]) +def test_license_destination_cannot_escape_or_overwrite_an_artifact(tmp_path, cargo_runner, destination): + plan = make_plan(tmp_path, licenses={"destination": destination}) + + try: + with pytest.raises(ValueError, match="destination"): + plan.execute() + finally: + plan.cleanup() + + +def test_check_previous_requires_a_reviewed_bundle(tmp_path): + with pytest.raises(ValueError, match="previous"): + make_plan(tmp_path, licenses={"check-previous": True}) + + +def test_check_previous_rejects_unreviewed_license_additions(tmp_path, cargo_runner, monkeypatch): + previous = tmp_path / "reviewed.json" + previous.write_text(dumps(BUNDLE)) + monkeypatch.setitem( + BUNDLE["third_party_libraries"][0], + "licenses", + [*BUNDLE["third_party_libraries"][0]["licenses"], {"license": "Zlib", "text": "New, unreviewed license text"}], + ) + plan = make_plan(tmp_path, licenses={"previous": "../reviewed.json", "check-previous": True}) + + try: + with pytest.raises(RuntimeError, match="reviewed"): + plan.execute() + finally: + plan.cleanup() + + +def test_existing_directory_is_not_a_license_file_destination(tmp_path, cargo_runner): + destination = tmp_path / "project/licenses" + destination.mkdir(parents=True) + (destination / "unrelated.txt").write_text("do not package this file") + plan = make_plan(tmp_path, licenses={"destination": "project/licenses"}) + + try: + with pytest.raises(ValueError, match="destination"): + plan.execute() + finally: + plan.cleanup() + + +@pytest.mark.parametrize("writer", ["generated-output", "artifact-manifest"]) +def test_license_bundle_cannot_be_overwritten_by_later_writers(tmp_path, cargo_runner, writer): + destination = "project/licenses/project.json" + artifacts: list[dict[str, Any]] = [{"name": "project", "destination": "project/lib/{shared_library}"}] + config: dict[str, Any] = { + "licenses": {}, + "artifacts": artifacts, + } + if writer == "generated-output": + rust = tmp_path / "rust project" + rust.mkdir() + (rust / "generated.txt").write_text("not a license bundle") + config["artifacts"].append({"name": "generated", "outputs": [{"source": "generated.txt", "destination": destination}]}) + else: + config.update({"artifact-manifest": True, "artifact-manifest-destination": destination}) + plan = make_plan(tmp_path, **config) + + try: + with pytest.raises(ValueError, match="destination"): + plan.execute() + finally: + plan.cleanup() + + +@pytest.mark.parametrize( + "cargo_args", + [ + ["--features", "extra"], + ["--features=extra"], + ["-Fextra"], + ["-vFextra"], + ["-qvFextra"], + ["-rFextra"], + ["-hFextra"], + ["--all-features"], + ["--package=second"], + ["-psecond"], + ["-vpsecond"], + ["-rpsecond"], + ["--manifest-path", "other/Cargo.toml"], + ["--target=wasm32-unknown-unknown"], + ], +) +def test_license_collection_rejects_raw_dependency_selection_overrides(tmp_path, cargo_runner, cargo_args): + plan = make_plan(tmp_path, licenses={}, **{"cargo-args": cargo_args}) + + try: + with pytest.raises(ValueError, match="cargo-args.*structured"): + plan.execute() + finally: + plan.cleanup() diff --git a/hatch_rs/tests/test_projects.py b/hatch_rs/tests/test_projects.py index 2e54568..a93883d 100644 --- a/hatch_rs/tests/test_projects.py +++ b/hatch_rs/tests/test_projects.py @@ -3,7 +3,7 @@ from os import environ, listdir, pathsep from os.path import dirname from pathlib import Path -from shutil import rmtree +from shutil import rmtree, which from subprocess import PIPE, STDOUT, check_call, run from sys import executable, modules, path, platform, version_info from zipfile import ZipFile @@ -26,6 +26,61 @@ def _subprocess_env(): class TestProject: + @pytest.mark.skipif(which("cargo-bundle-licenses") is None, reason="cargo-bundle-licenses CLI is not installed") + @pytest.mark.parametrize("workspace", [False, True]) + @pytest.mark.parametrize("newline", ["\n", "\r\n"], ids=["lf", "crlf"]) + def test_cargo_licenses_in_wheel_built_from_sdist(self, tmp_path, workspace, newline): + package = tmp_path / "license_project" + package.mkdir() + (package / "__init__.py").write_text("") + rust = tmp_path / "rust" + crate = rust / "app" if workspace else rust + (crate / "src").mkdir(parents=True) + (crate / "src/lib.rs").write_text('#[no_mangle]\npub extern "C" fn answer() -> u32 { licensed_dependency::answer() }\n') + deps = "../deps" if workspace else "deps" + (crate / "Cargo.toml").write_text( + '[package]\nname = "license_project"\nversion = "0.1.0"\nedition = "2021"\n' + '[lib]\ncrate-type = ["cdylib"]\n' + f'[dependencies]\nlicensed-dependency = {{ path = "{deps}/licensed-dependency" }}\n' + f'extra-dependency = {{ path = "{deps}/extra-dependency", optional = true }}\n' + '[features]\nextra = ["dep:extra-dependency"]\n' + ) + if workspace: + (rust / "Cargo.toml").write_text('[workspace]\nmembers = ["app", "deps/*"]\nresolver = "2"\n') + license_bytes = (REPO_ROOT / "LICENSE").read_text(encoding="utf-8").replace("\n", newline).encode("utf-8") + for name in ("licensed-dependency", "extra-dependency"): + dependency = rust / "deps" / name + (dependency / "src").mkdir(parents=True) + (dependency / "Cargo.toml").write_text(f'[package]\nname = "{name}"\nversion = "1.0.0"\nedition = "2021"\nlicense = "Apache-2.0"\n') + (dependency / "src/lib.rs").write_text("pub fn answer() -> u32 { 42 }\n") + (dependency / "LICENSE").write_bytes(license_bytes) + (tmp_path / "pyproject.toml").write_text( + '[build-system]\nrequires = ["hatchling", "hatch-rs"]\nbuild-backend = "hatchling.build"\n' + '[project]\nname = "license-project"\nversion = "0.1.0"\n' + '[tool.hatch.build.targets.wheel]\npackages = ["license_project"]\n' + '[tool.hatch.build.targets.sdist]\ninclude = ["license_project", "rust", "pyproject.toml"]\n' + '[tool.hatch.build.hooks.hatch-rs]\nmodule = "license_project"\npath = "rust"\n' + 'target-dir = "isolated"\nall-features = true\nartifact-manifest = true\n' + "[tool.hatch.build.hooks.hatch-rs.licenses]\n" + ) + if workspace: + with (tmp_path / "pyproject.toml").open("a") as config: + config.write( + '[[tool.hatch.build.hooks.hatch-rs.artifacts]]\nname = "license_project"\npackage = "license_project"\n' + 'destination = "license_project/{python_extension_name}"\n' + ) + + check_call([executable, "-m", "build", "-n"], cwd=tmp_path, env=_subprocess_env()) + + bundle_name = "license_project" if workspace else "cargo" + with ZipFile(next((tmp_path / "dist").glob("*.whl"))) as wheel: + bundle = loads(wheel.read(f"license_project/licenses/{bundle_name}.json")) + manifest = loads(wheel.read("license_project/lib/hatch-rs-artifacts.json")) + assert {library["package_name"] for library in bundle["third_party_libraries"]} == {"licensed-dependency", "extra-dependency"} + for library in bundle["third_party_libraries"]: + assert library["licenses"][0]["text"].encode("utf-8") == license_bytes + assert any(record["role"] == "third-party-licenses" for record in manifest["artifacts"]) + @pytest.mark.parametrize( "project_folder", [