blob: 5785cf80387515f04f0aab07acfff51a57be41de [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Generate artifact-exact legal metadata for native release artifacts."""
from __future__ import annotations
import argparse
import html
import json
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
CARGO_ABOUT_VERSION = "0.9.1"
PYTHON_TARGETS = (
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc",
)
GO_REPORTS = {
"x86_64-unknown-linux-gnu": "THIRD-PARTY-LICENSES.linux.amd64.html",
"aarch64-unknown-linux-gnu": "THIRD-PARTY-LICENSES.linux.arm64.html",
"x86_64-apple-darwin": "THIRD-PARTY-LICENSES.darwin.amd64.html",
"aarch64-apple-darwin": "THIRD-PARTY-LICENSES.darwin.arm64.html",
}
@dataclass(frozen=True)
class Report:
component: str
manifest: str
target: str
output: str
@dataclass(frozen=True)
class LicenseCorrection:
crates: tuple[str, ...]
license_crate: str
license_path: str
license_name: str
anchor: str
license_from_repository: bool = False
expected_versions: tuple[tuple[str, str], ...] = ()
@dataclass(frozen=True)
class BundledComponent:
crate: str
license_path: str
component: str
component_url: str
license_name: str
anchor: str
components: tuple[str, ...] = ()
targets: tuple[str, ...] = ()
license_from_repository: bool = False
required: bool = False
relationship: str = "bundled by"
crate_version: str = ""
required_features: tuple[str, ...] = ()
ALLOC_CORRECTIONS = (
LicenseCorrection(
crates=("alloc-stdlib",),
license_crate="alloc-no-stdlib",
license_path="LICENSE",
license_name="BSD 3-Clause License (rust-alloc-no-stdlib)",
anchor="bsd-rust-alloc-no-stdlib",
),
LicenseCorrection(
crates=("aws-lc-sys",),
license_crate="aws-lc-sys",
license_path="LICENSE",
license_name="AWS-LC License",
anchor="aws-lc-license",
),
)
COMMON_MIT_CORRECTIONS = (
LicenseCorrection(
crates=("async-stream", "async-stream-impl"),
license_crate="async-stream",
license_path="LICENSE",
license_name="MIT License (async-stream workspace)",
anchor="mit-async-stream-workspace",
),
LicenseCorrection(
crates=("brotli-decompressor",),
license_crate="brotli-decompressor",
license_path="LICENSE",
license_name="MIT License (brotli-decompressor)",
anchor="mit-brotli-decompressor",
),
LicenseCorrection(
crates=("libm",),
license_crate="libm",
license_path="LICENSE.txt",
license_name="MIT License (libm)",
anchor="mit-libm",
),
)
PYTHON_MIT_CORRECTIONS = (
LicenseCorrection(
crates=("jieba-macros", "jieba-rs"),
license_crate="jieba-rs",
license_path="third-party-licenses/jieba-rs-0.10.3.LICENSE",
license_name="MIT License (jieba-rs workspace)",
anchor="mit-jieba-rs-workspace",
license_from_repository=True,
expected_versions=(
("jieba-macros", "0.10.3"),
("jieba-rs", "0.10.3"),
),
),
LicenseCorrection(
crates=(
"ownedbytes",
"tantivy-bitpacker",
"tantivy-columnar",
"tantivy-common",
"tantivy-query-grammar",
"tantivy-sstable",
"tantivy-stacker",
"tantivy-tokenizer-api",
),
license_crate="tantivy",
license_path="LICENSE",
license_name="MIT License (Tantivy workspace)",
anchor="mit-tantivy-workspace",
),
)
BUNDLED_COMPONENTS = (
BundledComponent(
crate="zstd-sys",
license_path="zstd/LICENSE",
component="vendored Zstandard C sources",
component_url="https://github.com/facebook/zstd",
license_name="BSD 3-Clause License",
anchor="bundled-zstandard-bsd-3-clause",
),
BundledComponent(
crate="rust-stemmers",
license_path="algorithms/LICENSE",
component="generated Snowball stemming algorithms",
component_url="https://snowballstem.org/",
license_name="BSD 3-Clause License",
anchor="bundled-snowball-bsd-3-clause",
),
BundledComponent(
crate="regex-syntax",
license_path="src/unicode_tables/LICENSE-UNICODE",
component="generated Unicode character database tables",
component_url="https://www.unicode.org/",
license_name="Unicode Data Files and Software License",
anchor="bundled-regex-syntax-unicode",
),
BundledComponent(
crate="liblzma-sys",
license_path="xz/COPYING.0BSD",
component="XZ Utils 5.8.3 liblzma C sources",
component_url="https://github.com/tukaani-project/xz/tree/v5.8.3",
license_name="BSD Zero Clause License",
anchor="bundled-xz-utils-0bsd",
components=("python",),
required=True,
relationship="statically linked through",
crate_version="0.4.7",
required_features=("static",),
),
BundledComponent(
crate="openssl-sys",
license_path="third-party-licenses/openssl-1.1.1.LICENSE",
component="OpenSSL 1.1.1k FIPS libssl and libcrypto shared libraries",
component_url="https://github.com/openssl/openssl/tree/OpenSSL_1_1_1k",
license_name="OpenSSL 1.1.1 and Original SSLeay Licenses",
anchor="bundled-openssl-1.1.1",
components=("python",),
targets=("x86_64-unknown-linux-gnu",),
license_from_repository=True,
required=True,
relationship="linked through",
),
BundledComponent(
crate="openssl-sys",
license_path="third-party-licenses/openssl-1.1.1.LICENSE",
component="OpenSSL 1.1.1w libssl and libcrypto shared libraries",
component_url="https://github.com/openssl/openssl/tree/OpenSSL_1_1_1w",
license_name="OpenSSL 1.1.1 and Original SSLeay Licenses",
anchor="bundled-openssl-1.1.1",
components=("python",),
targets=("aarch64-unknown-linux-gnu",),
license_from_repository=True,
required=True,
relationship="linked through",
),
)
ALLOC_PLACEHOLDER = "Copyright (c) <year> <owner>."
MIT_PLACEHOLDER = "Copyright (c) <year> <copyright holders>"
LICENSE_PLACEHOLDERS = (
"<year>",
"<owner>",
"<copyright holders>",
"<year>",
"<owner>",
"<copyright holders>",
)
ASF_DEVELOPED_NOTICE = re.compile(
r"This product includes software developed at\n"
r"The Apache Software Foundation \(https?://www\.apache\.org/\)\."
)
def repository_root() -> Path:
return Path(
subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
)
def report_specs() -> list[Report]:
reports = [
Report(
component="python",
manifest="bindings/python/Cargo.toml",
target=target,
output=(f"bindings/python/licenses/{target}/THIRD-PARTY-LICENSES.html"),
)
for target in PYTHON_TARGETS
]
reports.extend(
Report(
component="go",
manifest="bindings/c/Cargo.toml",
target=target,
output=f"bindings/go/{filename}",
)
for target, filename in GO_REPORTS.items()
)
return reports
def verify_cargo_about(root: Path) -> None:
output = subprocess.check_output(
["cargo", "about", "--version"], cwd=root, text=True
).strip()
actual = output.rsplit(" ", 1)[-1]
if actual != CARGO_ABOUT_VERSION:
raise RuntimeError(
f"cargo-about {CARGO_ABOUT_VERSION} is required, found {output!r}"
)
def cargo_metadata(root: Path, report: Report) -> dict:
output = subprocess.check_output(
[
"cargo",
"metadata",
"--locked",
"--format-version",
"1",
"--manifest-path",
report.manifest,
"--filter-platform",
report.target,
],
cwd=root,
text=True,
)
metadata = json.loads(output)
tree = subprocess.check_output(
[
"cargo",
"tree",
"--locked",
"--manifest-path",
report.manifest,
"--target",
report.target,
"--edges",
"normal",
"--prefix",
"none",
"--format",
"{p}",
],
cwd=root,
text=True,
)
packages = set()
for line in tree.splitlines():
match = re.match(r"^([A-Za-z0-9_-]+) v([^ ]+)", line)
if match is None:
raise RuntimeError(f"unexpected cargo tree package line: {line!r}")
packages.add(match.groups())
metadata["normal_packages"] = packages
return metadata
def generate_base_report(root: Path, report: Report, output: Path) -> str:
subprocess.run(
[
"cargo",
"about",
"generate",
"--frozen",
"--fail",
"--config",
str(root / "about.toml"),
"--manifest-path",
report.manifest,
"--target",
report.target,
"--output-file",
str(output),
str(root / "about.hbs"),
],
cwd=root,
check=True,
)
return output.read_text(encoding="utf-8")
def resolved_packages(metadata: dict) -> list[dict]:
normal_packages = metadata["normal_packages"]
return [
package
for package in metadata["packages"]
if (package["name"], package["version"]) in normal_packages
]
def packages_by_name(metadata: dict, crate_name: str) -> list[dict]:
return [
package
for package in resolved_packages(metadata)
if package["name"] == crate_name
]
def package_by_name(
metadata: dict, crate_name: str, required: bool = True
) -> dict | None:
matches = packages_by_name(metadata, crate_name)
if not matches and not required:
return None
if len(matches) != 1:
versions = [package["version"] for package in matches]
raise RuntimeError(
f"expected exactly one resolved {crate_name} package, found {versions}"
)
return matches[0]
def package_features(metadata: dict, package: dict) -> set[str]:
matches = [
node["features"]
for node in metadata["resolve"]["nodes"]
if node["id"] == package["id"]
]
if len(matches) != 1:
raise RuntimeError(f"could not resolve features for {package['name']}")
return set(matches[0])
def correction_html(
root: Path, metadata: dict, correction: LicenseCorrection
) -> str:
expected_versions = dict(correction.expected_versions)
used_by = []
for crate_name in correction.crates:
packages = packages_by_name(metadata, crate_name)
if not packages:
raise RuntimeError(f"expected at least one resolved {crate_name} package")
for package in packages:
if correction.license_from_repository:
expected_version = expected_versions.get(crate_name)
if expected_version is None:
raise RuntimeError(
"repository-backed correction is missing an expected "
f"version for {crate_name}"
)
if package["version"] != expected_version:
raise RuntimeError(
f"expected {crate_name} {expected_version}, "
f"found {package['version']}"
)
repository = package.get("repository") or (
f"https://crates.io/crates/{crate_name}"
)
used_by.append(
f' <li><a href="{html.escape(repository, quote=True)}">'
f"{html.escape(crate_name)} {html.escape(package['version'])}</a></li>"
)
if correction.license_from_repository:
license_file = root / correction.license_path
if not license_file.is_file():
raise RuntimeError(f"corrected license file is missing: {license_file}")
license_text = license_file.read_text(encoding="utf-8")
else:
license_files = [
Path(package["manifest_path"]).parent / correction.license_path
for package in packages_by_name(metadata, correction.license_crate)
]
if not license_files:
raise RuntimeError(
f"expected at least one resolved {correction.license_crate} package"
)
missing = [path for path in license_files if not path.is_file()]
if missing:
raise RuntimeError(f"corrected license file is missing: {missing[0]}")
license_texts = {
path.read_text(encoding="utf-8") for path in license_files
}
if len(license_texts) != 1:
raise RuntimeError(
f"corrected license files differ for {correction.license_crate}"
)
license_text = license_texts.pop()
return "\n".join(
[
' <li class="license corrected-license">',
f' <h3 id="{html.escape(correction.anchor)}">'
f"{html.escape(correction.license_name)}</h3>",
" <h4>Used by</h4>",
' <ul class="license-used-by">',
*used_by,
" </ul>",
f' <pre class="license-text">{html.escape(license_text)}</pre>',
" </li>",
]
)
def replace_placeholder_entry(
root: Path,
report_text: str,
metadata: dict,
marker: str,
corrections: tuple[LicenseCorrection, ...],
) -> str:
if report_text.count(marker) != 1:
raise RuntimeError(
f"expected exactly one license placeholder {marker!r}, "
f"found {report_text.count(marker)}"
)
marker_index = report_text.index(marker)
entry_start = report_text.rfind(' <li class="license">', 0, marker_index)
entry_end = report_text.find(" </li>", marker_index)
if entry_start == -1 or entry_end == -1:
raise RuntimeError(f"could not locate license entry for {marker!r}")
entry_end += len(" </li>")
placeholder_entry = report_text[entry_start:entry_end]
actual_crates = set(
re.findall(
r'<li><a href="[^"]+">([A-Za-z0-9_-]+) [^<]+</a></li>',
placeholder_entry,
)
)
expected_crates = {
crate_name for correction in corrections for crate_name in correction.crates
}
if actual_crates != expected_crates:
raise RuntimeError(
f"placeholder dependency set changed for {marker!r}: expected "
f"{sorted(expected_crates)}, found {sorted(actual_crates)}"
)
replacement = "\n".join(
correction_html(root, metadata, correction) for correction in corrections
)
return report_text[:entry_start] + replacement + report_text[entry_end:]
def bundled_component_html(
root: Path, report: Report, report_text: str, metadata: dict
) -> str:
items = []
for component in BUNDLED_COMPONENTS:
if component.components and report.component not in component.components:
continue
if component.targets and report.target not in component.targets:
continue
package = package_by_name(metadata, component.crate, required=False)
if package is None:
if component.required:
raise RuntimeError(
f"required bundled component crate is missing: {component.crate}"
)
continue
if component.crate_version and package["version"] != component.crate_version:
raise RuntimeError(
f"expected {component.crate} {component.crate_version}, "
f"found {package['version']}"
)
missing_features = set(component.required_features) - package_features(
metadata, package
)
if missing_features:
raise RuntimeError(
f"{component.crate} is missing required features: "
f"{sorted(missing_features)}"
)
marker = f">{component.crate} {package['version']}</a>"
if marker not in report_text:
if component.required:
raise RuntimeError(
f"required bundled component is absent from report: {component.crate}"
)
# Cargo metadata may include workspace-unified features.
continue
if component.license_from_repository:
license_file = root / component.license_path
else:
license_file = (
Path(package["manifest_path"]).parent / component.license_path
)
if not license_file.is_file():
raise RuntimeError(f"bundled license file is missing: {license_file}")
license_text = license_file.read_text(encoding="utf-8")
repository = package.get("repository") or (
f"https://crates.io/crates/{component.crate}"
)
items.append(
"\n".join(
[
' <li class="license bundled-subcomponent">',
f' <h3 id="{html.escape(component.anchor)}">'
f"{html.escape(component.license_name)}</h3>",
" <h4>Bundled component</h4>",
' <ul class="license-used-by">',
" <li>",
f' <a href="{html.escape(component.component_url, quote=True)}">'
f"{html.escape(component.component)}</a>, "
f"{html.escape(component.relationship)}",
f' <a href="{html.escape(repository, quote=True)}">'
f"{html.escape(component.crate)} {html.escape(package['version'])}</a>",
" </li>",
" </ul>",
f' <pre class="license-text">{html.escape(license_text)}</pre>',
" </li>",
]
)
)
if not items:
return ""
return "\n".join(
[
"",
" <h2>Licenses for bundled native and source components</h2>",
" <p>",
" These components ship with the native artifact but have",
" license files outside their Rust package metadata.",
" </p>",
' <ul class="licenses-list bundled-subcomponents">',
*items,
" </ul>",
]
)
def complete_report(
root: Path, base_report: str, report: Report, metadata: dict
) -> str:
result = replace_placeholder_entry(
root, base_report, metadata, ALLOC_PLACEHOLDER, ALLOC_CORRECTIONS
)
mit_corrections = COMMON_MIT_CORRECTIONS
if report.component == "python":
mit_corrections += PYTHON_MIT_CORRECTIONS
result = replace_placeholder_entry(
root, result, metadata, MIT_PLACEHOLDER, mit_corrections
)
for placeholder in LICENSE_PLACEHOLDERS:
if placeholder in result:
raise RuntimeError(
f"generated report still contains license placeholder {placeholder!r}"
)
description = (
"\n <p><strong>Artifact:</strong> "
f"<code>{html.escape(report.component)}</code></p>"
"\n <p><strong>Rust target:</strong> "
f"<code>{html.escape(report.target)}</code></p>"
"\n <p><strong>Root manifest:</strong> "
f"<code>{html.escape(report.manifest)}</code></p>"
)
first_paragraph_end = result.find("</p>")
if first_paragraph_end == -1:
raise RuntimeError("about.hbs output has no introductory paragraph")
first_paragraph_end += len("</p>")
result = result[:first_paragraph_end] + description + result[first_paragraph_end:]
closing_main = result.rfind(" </main>")
if closing_main == -1:
raise RuntimeError("about.hbs output has no closing main element")
result = (
result[:closing_main]
+ bundled_component_html(root, report, result, metadata)
+ "\n"
+ result[closing_main:]
)
return "\n".join(line.rstrip() for line in result.rstrip().splitlines()) + "\n"
def binary_license(apache_license: str, heading: str, reports: list[str]) -> str:
appendix = [
"",
"=" * 79,
"BUNDLED THIRD-PARTY COMPONENTS",
"=" * 79,
"",
heading,
"The component inventory, copyright notices, and complete license texts",
"are provided in:",
"",
]
appendix.extend(f" {report}" for report in reports)
return apache_license.rstrip() + "\n" + "\n".join(appendix) + "\n"
def notice_paragraphs(text: str) -> list[str]:
normalized = "\n".join(line.rstrip() for line in text.strip().splitlines())
return [part.strip() for part in re.split(r"\n\s*\n", normalized) if part.strip()]
def binary_notice(root: Path, metadata_by_target: list[dict]) -> str:
paragraphs = notice_paragraphs((root / "NOTICE").read_text(encoding="utf-8"))
seen_paragraphs = set(paragraphs)
seen_packages = set()
for metadata in metadata_by_target:
for package in resolved_packages(metadata):
package_key = (
package["name"],
package["version"],
package["manifest_path"],
)
if package_key in seen_packages:
continue
seen_packages.add(package_key)
package_dir = Path(package["manifest_path"]).parent
notice_files = sorted(
path
for path in package_dir.iterdir()
if path.is_file()
and path.name.lower() in {"notice", "notice.txt", "notice.md"}
)
for notice_file in notice_files:
notice_text = notice_file.read_text(encoding="utf-8")
for paragraph in notice_paragraphs(notice_text):
if ASF_DEVELOPED_NOTICE.fullmatch(paragraph):
continue
if paragraph in seen_paragraphs:
continue
seen_paragraphs.add(paragraph)
paragraphs.append(paragraph)
return "\n\n".join(paragraphs) + "\n"
def verify_python_source_legal_files(root: Path) -> None:
for name in ("LICENSE", "NOTICE"):
source = root / name
copy = root / "bindings/python" / name
if source.read_bytes() != copy.read_bytes():
raise RuntimeError(f"restore bindings/python/{name} before generation")
source_report = root / "bindings/python/THIRD-PARTY-LICENSES.html"
report_text = source_report.read_text(encoding="utf-8")
for marker in (
"Python source distribution licenses",
"contains no compiled native library",
):
if marker not in report_text:
raise RuntimeError(f"Python source license report is missing {marker!r}")
def generate_reports(
root: Path, reports: list[Report]
) -> tuple[dict[Report, str], dict[Report, dict]]:
verify_cargo_about(root)
result = {}
metadata_by_report = {}
with tempfile.TemporaryDirectory(prefix="paimon-rust-license-reports-") as temp:
temp_root = Path(temp)
for index, report in enumerate(reports):
print(f"generating {report.component} license report for {report.target}")
base = generate_base_report(
root, report, temp_root / f"report-{index}.html"
)
metadata = cargo_metadata(root, report)
metadata_by_report[report] = metadata
result[report] = complete_report(root, base, report, metadata)
return result, metadata_by_report
def python_binary_files(
root: Path, target: str, report: str, metadata: dict
) -> dict[str, str]:
apache_license = (root / "LICENSE").read_text(encoding="utf-8")
return {
"LICENSE": binary_license(
apache_license,
f"This binary wheel bundles the Rust native library for {target}.",
["THIRD-PARTY-LICENSES.html"],
),
"NOTICE": binary_notice(root, [metadata]),
"THIRD-PARTY-LICENSES.html": report,
}
def python_target_files(root: Path, target: str, output_dir: Path) -> dict[Path, str]:
if target not in PYTHON_TARGETS:
raise ValueError(f"unsupported Python release target: {target}")
verify_python_source_legal_files(root)
report = next(
item
for item in report_specs()
if item.component == "python" and item.target == target
)
reports, metadata = generate_reports(root, [report])
return {
output_dir / name: content
for name, content in python_binary_files(
root, target, reports[report], metadata[report]
).items()
}
def python_verification_files(root: Path, output_dir: Path) -> dict[Path, str]:
verify_python_source_legal_files(root)
reports = [report for report in report_specs() if report.component == "python"]
generated, metadata = generate_reports(root, reports)
result = {}
for report in reports:
target_dir = output_dir / report.target
result.update(
{
target_dir / name: content
for name, content in python_binary_files(
root,
report.target,
generated[report],
metadata[report],
).items()
}
)
return result
def go_target_files(root: Path, target: str, output_dir: Path) -> dict[Path, str]:
if target not in GO_REPORTS:
raise ValueError(f"unsupported Go release target: {target}")
report = next(
item
for item in report_specs()
if item.component == "go" and item.target == target
)
generated, _ = generate_reports(root, [report])
return {output_dir / GO_REPORTS[target]: generated[report]}
def go_release_files(root: Path, output_dir: Path) -> dict[Path, str]:
reports = [report for report in report_specs() if report.component == "go"]
generated, metadata = generate_reports(root, reports)
apache_license = (root / "LICENSE").read_text(encoding="utf-8")
result = {
output_dir / GO_REPORTS[report.target]: generated[report] for report in reports
}
result[output_dir / "LICENSE"] = binary_license(
apache_license,
"This Go module bundles Rust native libraries for four release targets.",
list(GO_REPORTS.values()),
)
result[output_dir / "NOTICE"] = binary_notice(
root, [metadata[report] for report in reports]
)
return result
def generated_files(root: Path) -> dict[Path, str]:
verify_python_source_legal_files(root)
reports = report_specs()
generated, metadata = generate_reports(root, reports)
result = {root / report.output: generated[report] for report in reports}
apache_license = (root / "LICENSE").read_text(encoding="utf-8")
python_reports = [report for report in reports if report.component == "python"]
for report in python_reports:
license_dir = root / "bindings/python/licenses" / report.target
files = python_binary_files(
root, report.target, generated[report], metadata[report]
)
for name in ("LICENSE", "NOTICE"):
result[license_dir / name] = files[name]
go_reports = list(GO_REPORTS.values())
result[root / "bindings/go/LICENSE"] = binary_license(
apache_license,
"This Go module bundles Rust native libraries for four release targets.",
go_reports,
)
result[root / "bindings/go/NOTICE"] = binary_notice(
root,
[
metadata[report]
for report in reports
if report.component == "go"
],
)
return result
def check_generated_files(files: dict[Path, str], root: Path) -> None:
relative_paths = [str(path.relative_to(root)) for path in files]
tracked = subprocess.check_output(
["git", "ls-files", "--", *relative_paths], cwd=root, text=True
).splitlines()
if tracked:
raise RuntimeError(
"generated binary legal files must not be tracked: "
+ ", ".join(tracked)
)
python_paths = [
relative
for relative in relative_paths
if relative.startswith("bindings/python/licenses/")
]
for relative in python_paths:
attribute = subprocess.check_output(
["git", "check-attr", "export-ignore", "--", relative],
cwd=root,
text=True,
).strip()
if not attribute.endswith(": set"):
raise RuntimeError(f"generated binary legal file is not export-ignored: {relative}")
print(f"validated {len(files)} generated binary legal files")
def write_files(files: dict[Path, str], root: Path) -> None:
for path, content in files.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content.encode("utf-8"))
display_path = path.relative_to(root) if path.is_relative_to(root) else path
print(f"generated {display_path}")
def verify_existing_go_reports(files: dict[Path, str]) -> None:
report_names = set(GO_REPORTS.values())
for path, expected in files.items():
if path.name not in report_names:
continue
if not path.is_file():
raise RuntimeError(f"missing generated Go report: {path}")
if path.read_bytes() != expected.encode("utf-8"):
raise RuntimeError(f"generated Go report differs from release input: {path}")
def stage_python(root: Path, target: str) -> None:
write_files(python_target_files(root, target, root / "bindings/python"), root)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"--check",
action="store_true",
help="validate reproducible binary legal output without writing it",
)
group.add_argument(
"--stage-python",
metavar="TARGET",
help="stage one target's legal files for a maturin wheel build",
)
group.add_argument(
"--generate-python-reports",
metavar="DIRECTORY",
help="generate all target legal files for final wheel verification",
)
group.add_argument(
"--generate-go-report",
nargs=2,
metavar=("TARGET", "DIRECTORY"),
help="generate one target report for a Go build job",
)
group.add_argument(
"--generate-go-release",
metavar="DIRECTORY",
help="verify Go target reports and generate aggregate legal files",
)
args = parser.parse_args()
root = repository_root()
try:
if args.stage_python:
stage_python(root, args.stage_python)
return 0
if args.generate_python_reports:
files = python_verification_files(
root, Path(args.generate_python_reports).resolve()
)
write_files(files, root)
return 0
if args.generate_go_report:
target, directory = args.generate_go_report
files = go_target_files(root, target, Path(directory).resolve())
write_files(files, root)
return 0
if args.generate_go_release:
files = go_release_files(root, Path(args.generate_go_release).resolve())
verify_existing_go_reports(files)
write_files(
{
path: content
for path, content in files.items()
if path.name in {"LICENSE", "NOTICE"}
},
root,
)
return 0
files = generated_files(root)
except (OSError, RuntimeError, ValueError, subprocess.CalledProcessError) as error:
print(f"release license generation failed: {error}", file=sys.stderr)
return 1
if args.check:
try:
check_generated_files(files, root)
except (OSError, RuntimeError, subprocess.CalledProcessError) as error:
print(f"release license generation failed: {error}", file=sys.stderr)
return 1
return 0
write_files(files, root)
return 0
if __name__ == "__main__":
sys.exit(main())