diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95942cfb0e..71554b4c01 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,9 +34,12 @@ repos: rev: v6.2.0 hooks: - id: reuse-lint-file - name: REUSE lint (SPDX headers on changed files) - # Include CMakeLists.txt and Dockerfile.xxx by matching those explicitly in the regex - files: \.(py|js|ts|cpp|c|h|hpp|cxx|hxx|cmake|md|yaml|yml|sh|fbs|toml|html)$|CMakeLists\.txt$|Dockerfile(\.[A-Za-z0-9_-]+)?$ + name: REUSE licenses (source files and complete EgoPost subtree) + entry: python scripts/check_licenses.py --fix + args: [] # Clear the upstream hook's lint-file argument. + stages: [pre-commit] + pass_filenames: false + always_run: true # Copyright year: ensure SPDX-FileCopyrightText includes current year (--fix updates in place) - repo: local hooks: diff --git a/AGENTS.md b/AGENTS.md index 5d42231f8f..1f66146d09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,6 +47,24 @@ If you cannot run a shell, use your search/glob tools on the pattern `**/AGENTS. Optional context index: [`src/core/AGENTS.md`](src/core/AGENTS.md) (also on the ancestor walk—read it when working under `src/core/`). +## Product naming + +- Spell the product name “Isaac Capture” in prose; avoid the ambiguous shorthand “Capture”. + +## Component licensing + +- Keep licensing summaries brief in README and CONTRIBUTING; automate routine + checks and updates rather than documenting manual header maintenance. + +- Preserve root `LICENSE.md` verbatim as Apache-2.0; explain component exceptions + in README/CONTRIBUTING and local license files, so GitHub can recognize the default. +- EgoPost uses `LicenseRef-OpenMDW-1.1`; preserve upstream exceptions and notices. + Keep license-boundary changes separate from copyright-template and year-policy changes. +- Keep canonical texts in `LICENSES/` unmodified, including template placeholders; + do not prepend project copyright or SPDX headers or include them in header repair. +- Source hosting does not authorize wheel inclusion. Keep package discovery explicit + and verify license metadata/notices in each artifact before shipping a new component. + ## CMake & include structure When you create or edit a `CMakeLists.txt`, add or move a target, place a header, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1a2bc161a..ae507e62ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,10 @@ Thanks for your interest in contributing! This document outlines how to build, t - Follow PEP 8 for style. - Use type annotations (PEP 484) and prefer formatted string literals (PEP 701, f-strings). +## License + +- Contributions use the license of the component/file being changed: Apache-2.0 by default, OpenMDW-1.1 for EgoPost, with explicit third-party exceptions preserved. The DCO below applies to each contribution. + ## Pre-commit We use [pre-commit](https://pre-commit.com/) to run Python linters and formatters (including @@ -50,31 +54,10 @@ ruff format . ### License headers -We use [REUSE](https://reuse.software/) to enforce SPDX license headers on source files. Pre-commit runs `reuse lint-file` on changed files (e.g. `.py`, `.cpp`, `.md`, `.yaml`): - -1. **Required fields**: Each file must have `SPDX-FileCopyrightText` and `SPDX-License-Identifier`. -2. **License**: `SPDX-License-Identifier` must be `Apache-2.0` (full text in `LICENSES/Apache-2.0.txt`). - -Example header: - -``` -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 -``` - -To add or fix headers, install the [reuse tool](https://github.com/fsfe/reuse-tool) - -```bash -uv tool install reuse -``` - -and run for example: - -```bash -reuse annotate -t compact -l Apache-2.0 --skip-unrecognised -r path/to/file -``` - -REUSE does not auto-update the copyright year when you touch a file; include the current year when adding or editing headers. +Pre-commit automatically checks and updates license headers with [REUSE](https://reuse.software/). +It adds NVIDIA headers to unannotated first-party files and selects the license by +file location, preserving existing attribution and explicit third-party exceptions. +Review and stage any automatic edits, then commit again. ## CI GitHub Actions builds the default targets and runs tests. @@ -85,10 +68,7 @@ GitHub Actions builds the default targets and runs tests. 3. Describe motivation, changes, and testing in the PR 4. Link related issues -## License -- Your contributions are under the repository’s license unless stated otherwise - -### Signing Your Work +## Signing Your Work * We require that all contributors "sign-off" on their commits. This certifies that the contribution is your original work, or you have rights to submit it under the same license, or a compatible license. diff --git a/LICENSES/Apache-2.0.txt b/LICENSES/Apache-2.0.txt index 1a89b9054d..261eeb9e9f 100644 --- a/LICENSES/Apache-2.0.txt +++ b/LICENSES/Apache-2.0.txt @@ -178,7 +178,7 @@ APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" + boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright 2020 NVIDIA Corporation + Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/LICENSES/LicenseRef-OpenMDW-1.1.txt b/LICENSES/LicenseRef-OpenMDW-1.1.txt new file mode 100644 index 0000000000..ec297ac545 --- /dev/null +++ b/LICENSES/LicenseRef-OpenMDW-1.1.txt @@ -0,0 +1,49 @@ +OpenMDW License Agreement, version 1.1 (OpenMDW-1.1) + +By exercising rights granted to you under this agreement, you accept and agree +to its terms. + +As used in this agreement, "Model Materials" means the materials provided to +you under this agreement, consisting of: (1) one or more machine learning +models (including architecture and parameters); and (2) all related artifacts +(including associated data, documentation and software) that are provided to +you hereunder. + +Subject to your compliance with this agreement, permission is hereby granted, +free of charge, to deal in the Model Materials without restriction, including +under all copyright, patent, database, and trade secret rights included or +embodied therein. + +If you distribute any portion of the Model Materials, you shall retain in your +distribution (1) a copy of this agreement, and (2) all copyright notices and +other notices of origin included in the Model Materials that are applicable to +your distribution. + +If you file, maintain, or voluntarily participate in a lawsuit against any +person or entity asserting that the Model Materials directly or indirectly +infringe any patent or copyright, then all rights and grants made to you +hereunder are terminated, unless that lawsuit was in response to a +corresponding lawsuit first brought against you. + +This agreement does not impose any restrictions or obligations with respect to +any use, modification, or sharing of any outputs generated by using the Model +Materials. + +THE MODEL MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE, NONINFRINGEMENT, ACCURACY, OR THE +ABSENCE OF LATENT OR OTHER DEFECTS OR ERRORS, WHETHER OR NOT DISCOVERABLE, ALL +TO THE GREATEST EXTENT PERMISSIBLE UNDER APPLICABLE LAW. + +YOU ARE SOLELY RESPONSIBLE FOR (1) CLEARING RIGHTS OF OTHER PERSONS THAT MAY +APPLY TO THE MODEL MATERIALS OR ANY USE THEREOF, INCLUDING WITHOUT LIMITATION +ANY PERSON'S COPYRIGHTS OR OTHER RIGHTS INCLUDED OR EMBODIED IN THE MODEL +MATERIALS; (2) OBTAINING ANY NECESSARY CONSENTS, PERMISSIONS OR OTHER RIGHTS +REQUIRED FOR ANY USE OF THE MODEL MATERIALS; OR (3) PERFORMING ANY DUE +DILIGENCE OR UNDERTAKING ANY OTHER INVESTIGATIONS INTO THE MODEL MATERIALS OR +ANYTHING INCORPORATED OR EMBODIED THEREIN. + +IN NO EVENT SHALL THE PROVIDERS OF THE MODEL MATERIALS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MODEL MATERIALS, THE +USE THEREOF OR OTHER DEALINGS THEREIN. diff --git a/LICENSES/MIT.txt b/LICENSES/MIT.txt index 9cf106272a..242da6233b 100644 --- a/LICENSES/MIT.txt +++ b/LICENSES/MIT.txt @@ -1,5 +1,7 @@ MIT License +Copyright (c) + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights diff --git a/README.md b/README.md index 90a0379079..3cc6d168ce 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,9 @@ Our [documentation page](https://nvidia.github.io/IsaacCapture) provides everyth Isaac Teleop is design to work side by side with [NVIDIA Isaac Lab](https://github.com/isaac-sim/IsaacLab) starting with Isaac Lab [3.0 Beta release](https://github.com/isaac-sim/IsaacLab/releases/tag/v3.0.0-beta). To get started, please refer to Isaac Lab's [Installation](https://isaac-sim.github.io/IsaacLab/develop/source/setup/installation/index.html) guide for more details. Then follow the [CloudXR teleoperation in Isaac Lab](https://isaac-sim.github.io/IsaacLab/develop/source/how-to/cloudxr_teleoperation.html) to get started with Teleoperation in Sim. + +## Licensing + +The default license is [Apache-2.0](LICENSE.md). +EgoPost is licensed under [OpenMDW-1.1](src/python/isaaccapture_egopost/LICENSE.md). +Third-party materials may carry their own licenses. diff --git a/REUSE.toml b/REUSE.toml index b3e4d93312..38c21963fb 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -57,3 +57,10 @@ path = "deps/third_party/chromium/LICENSE" precedence = "override" SPDX-FileCopyrightText = "Copyright 2015 The Chromium Authors" SPDX-License-Identifier = "BSD-3-Clause" + +# EgoPost's default never overrides upstream headers or sidecar licenses. +# The license-only scaffold has no covered files yet; the canonical text is reserved for imports. +[[annotations]] +path = "src/python/isaaccapture_egopost/**" +precedence = "closest" +SPDX-License-Identifier = "LicenseRef-OpenMDW-1.1" diff --git a/pyproject.toml b/pyproject.toml index 695d841299..767461704b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ description = "Isaac Teleop" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" +license-files = ["LICENSE.md"] authors = [ { name = "NVIDIA" }, ] @@ -102,6 +103,8 @@ package = false isaacteleop = { path = "src/compat" } [tool.scikit-build] +# EgoPost is hosted here but is not part of the Isaac Capture distribution. +sdist.exclude = ["src/python/isaaccapture_egopost/**", "LICENSES/LicenseRef-OpenMDW-1.1.txt"] # Floor matches CMakeLists' cmake_minimum_required (3.24, for `cmake --fresh`). # Ubuntu 22.04's apt CMake is 3.22 and no longer satisfies it, so scikit-build-core # fetches CMake from PyPI there; the <4 cap keeps that fetch on the 3.x line rather diff --git a/scripts/check_licenses.py b/scripts/check_licenses.py new file mode 100644 index 0000000000..445342fbac --- /dev/null +++ b/scripts/check_licenses.py @@ -0,0 +1,176 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Check and fix Isaac Capture source headers, including all EgoPost assets.""" + +import argparse +import re +import subprocess +import sys +from datetime import date +from pathlib import Path + +from reuse.global_licensing import ReuseTOML +from reuse.project import Project + +EGOPOST_SUBTREE = Path("src/python/isaaccapture_egopost") +EGOPOST_LICENSE = "LicenseRef-OpenMDW-1.1" +DEFAULT_LICENSE = "Apache-2.0" +COPYRIGHT_HOLDER = "NVIDIA CORPORATION & AFFILIATES. All rights reserved." +SOURCE_FILES = re.compile( + r"\.(py|js|ts|cpp|c|h|hpp|cxx|hxx|cmake|md|yaml|yml|sh|fbs|toml|html)$" + r"|CMakeLists\.txt$|Dockerfile(\.[A-Za-z0-9_-]+)?$" +) + + +def covered_paths(project: Project) -> list[Path]: + # Check source files repository-wide; EgoPost additionally includes all assets. + paths = (path.relative_to(project.root) for path in project.all_files()) + return sorted( + path + for path in paths + if path.is_relative_to(EGOPOST_SUBTREE) or SOURCE_FILES.search(str(path)) + ) + + +def third_party_path(path: Path) -> bool: + # Directory exemptions follow the repository layout: third_party lives in deps. + return path.parts[0] == "deps" + + +def is_egopost_default(project: Project, path: Path, info) -> bool: + if info.source_type.value != "reuse-toml" or info.source_path != "REUSE.toml": + return False + annotation = ReuseTOML.from_file(project.root / "REUSE.toml").find_annotations_item( + path + ) + # A subtree fallback is policy, not an explicit per-file exception. + return annotation is not None and f"{EGOPOST_SUBTREE}/**" in annotation.paths + + +def check_egopost_default(project: Project) -> bool: + annotations = ReuseTOML.from_file(project.root / "REUSE.toml").annotations + defaults = [item for item in annotations if f"{EGOPOST_SUBTREE}/**" in item.paths] + if not defaults or any( + {str(expr) for expr in item.spdx_expressions} != {EGOPOST_LICENSE} + or item.precedence.value != "closest" + for item in defaults + ): + print( + f"REUSE.toml: EgoPost default must be {EGOPOST_LICENSE} with closest precedence" + ) + return False + return True + + +def first_party_licenses(project: Project, path: Path) -> set[str] | None: + if third_party_path(path): + return None + infos = project.reuse_info_of(path) + notices = {notice.name for info in infos for notice in info.copyright_notices} + explicit_exception = any( + info.spdx_expressions + and info.source_type.value != "file-header" + and not is_egopost_default(project, path, info) + for info in infos + ) + if ( + explicit_exception + or not notices + or not all("NVIDIA CORPORATION & AFFILIATES" in notice for notice in notices) + ): + return None + return {str(expr) for info in infos for expr in info.spdx_expressions} + + +def fix_license(project: Project, path: Path) -> bool: + if third_party_path(path): + return False + expected = ( + EGOPOST_LICENSE if path.is_relative_to(EGOPOST_SUBTREE) else DEFAULT_LICENSE + ) + infos = project.reuse_info_of(path) + notices = {notice for info in infos for notice in info.copyright_notices} + effective = {str(expr) for info in infos for expr in info.spdx_expressions} + expressions = first_party_licenses(project, path) + add_copyright = not notices and effective <= {expected} + if add_copyright or expressions == set(): + command = [ + sys.executable, + "-m", + "reuse", + "annotate", + "--fallback-dot-license", + "-l", + expected, + ] + if add_copyright: + command += ["-c", COPYRIGHT_HOLDER, "--year", str(date.today().year)] + subprocess.run(command + [str(path)], check=True) + return True + if expressions is None: + return False + if expected == EGOPOST_LICENSE and expressions == {"Apache-2.0"}: + content = path.read_text() + # Replace only the default header tag, retaining copyright and other notices. + updated, count = re.subn( + r"(SPDX-License-Identifier:[ \t]*)Apache-2\.0(?=[ \t]*(?:\*/|-->)?[ \t]*$)", + rf"\g<1>{EGOPOST_LICENSE}", + content, + count=1, + flags=re.MULTILINE, + ) + if count: + path.write_text(updated) + print(f"{path}: updated default license to {EGOPOST_LICENSE}") + return True + return False + + +def check_policy(project: Project, path: Path) -> bool: + expressions = first_party_licenses(project, path) + if expressions is not None and EGOPOST_LICENSE not in expressions: + print( + f"{path}: expected {EGOPOST_LICENSE}; document any license exception explicitly" + ) + return False + return True + + +def main(fix: bool = False) -> int: + project = Project.from_directory(Path.cwd()) + if not check_egopost_default(project): + return 1 + paths = covered_paths(project) + changed = False + if fix: + for path in paths: + changed = fix_license(project, path) or changed + project = Project.from_directory(Path.cwd()) + ok = True + # Pass complete coverage on every invocation, including metadata-only changes. + for start in range(0, len(paths), 200): + result = subprocess.run( + [sys.executable, "-m", "reuse", "lint-file", "--"] + + [str(path) for path in paths[start : start + 200]], + check=False, + ) + ok = result.returncode == 0 and ok + for path in paths: + if path.is_relative_to(EGOPOST_SUBTREE): + ok = check_policy(project, path) and ok + if changed: + print( + "License headers updated; review and stage the changes before committing." + ) + return 0 if ok and not changed else 1 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--fix", + action="store_true", + help="add default NVIDIA headers and repair default license tags", + ) + sys.exit(main(fix=parser.parse_args().fix)) diff --git a/src/core/python/CMakeLists.txt b/src/core/python/CMakeLists.txt index 4034dfc860..35ffc430ed 100644 --- a/src/core/python/CMakeLists.txt +++ b/src/core/python/CMakeLists.txt @@ -42,6 +42,7 @@ add_custom_target(python_package ALL COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_BINARY_DIR}/pyproject.toml" "${CMAKE_BINARY_DIR}/python_package/$/" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/MANIFEST.in" "${CMAKE_BINARY_DIR}/python_package/$/" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/setup.py" "${CMAKE_BINARY_DIR}/python_package/$/" + COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/LICENSE.md" "${CMAKE_BINARY_DIR}/python_package/$/LICENSE.md" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_SOURCE_DIR}/README.md" "${CMAKE_BINARY_DIR}/python_package/$/README.md" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/requirements.txt" "${CMAKE_BINARY_DIR}/python_package/$/" COMMAND ${CMAKE_COMMAND} -E copy "${CMAKE_CURRENT_SOURCE_DIR}/requirements-dev.txt" "${CMAKE_BINARY_DIR}/python_package/$/" diff --git a/src/core/python/pyproject.toml.in b/src/core/python/pyproject.toml.in index 4ade1d4327..fff1897dca 100644 --- a/src/core/python/pyproject.toml.in +++ b/src/core/python/pyproject.toml.in @@ -9,7 +9,7 @@ # See: numpy.org/devdocs/dev/depending_on_numpy.html [build-system] -requires = ["setuptools>=64", "wheel", "numpy>=2.0"] +requires = ["setuptools>=77", "wheel", "numpy>=2.0"] build-backend = "setuptools.build_meta" [project] @@ -19,6 +19,7 @@ description = "Isaac Teleop - Teleoperation with Device I/O" readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" +license-files = ["LICENSE.md"] authors = [ {name = "NVIDIA"} ] @@ -55,7 +56,8 @@ py-modules = ["isaacteleop"] # TODO(1.9): drop the compatibility module. where = ["."] namespaces = false include = [ - "isaaccapture*", + "isaaccapture", + "isaaccapture.*", "robotic_grounding", "robotic_grounding.retarget", ] diff --git a/src/python/isaaccapture_egopost/LICENSE.md b/src/python/isaaccapture_egopost/LICENSE.md new file mode 100644 index 0000000000..a5b8c94f0e --- /dev/null +++ b/src/python/isaaccapture_egopost/LICENSE.md @@ -0,0 +1,52 @@ +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + + +OpenMDW License Agreement, version 1.1 (OpenMDW-1.1) + +By exercising rights granted to you under this agreement, you accept and agree +to its terms. + +As used in this agreement, "Model Materials" means the materials provided to +you under this agreement, consisting of: (1) one or more machine learning +models (including architecture and parameters); and (2) all related artifacts +(including associated data, documentation and software) that are provided to +you hereunder. + +Subject to your compliance with this agreement, permission is hereby granted, +free of charge, to deal in the Model Materials without restriction, including +under all copyright, patent, database, and trade secret rights included or +embodied therein. + +If you distribute any portion of the Model Materials, you shall retain in your +distribution (1) a copy of this agreement, and (2) all copyright notices and +other notices of origin included in the Model Materials that are applicable to +your distribution. + +If you file, maintain, or voluntarily participate in a lawsuit against any +person or entity asserting that the Model Materials directly or indirectly +infringe any patent or copyright, then all rights and grants made to you +hereunder are terminated, unless that lawsuit was in response to a +corresponding lawsuit first brought against you. + +This agreement does not impose any restrictions or obligations with respect to +any use, modification, or sharing of any outputs generated by using the Model +Materials. + +THE MODEL MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE, NONINFRINGEMENT, ACCURACY, OR THE +ABSENCE OF LATENT OR OTHER DEFECTS OR ERRORS, WHETHER OR NOT DISCOVERABLE, ALL +TO THE GREATEST EXTENT PERMISSIBLE UNDER APPLICABLE LAW. + +YOU ARE SOLELY RESPONSIBLE FOR (1) CLEARING RIGHTS OF OTHER PERSONS THAT MAY +APPLY TO THE MODEL MATERIALS OR ANY USE THEREOF, INCLUDING WITHOUT LIMITATION +ANY PERSON'S COPYRIGHTS OR OTHER RIGHTS INCLUDED OR EMBODIED IN THE MODEL +MATERIALS; (2) OBTAINING ANY NECESSARY CONSENTS, PERMISSIONS OR OTHER RIGHTS +REQUIRED FOR ANY USE OF THE MODEL MATERIALS; OR (3) PERFORMING ANY DUE +DILIGENCE OR UNDERTAKING ANY OTHER INVESTIGATIONS INTO THE MODEL MATERIALS OR +ANYTHING INCORPORATED OR EMBODIED THEREIN. + +IN NO EVENT SHALL THE PROVIDERS OF THE MODEL MATERIALS BE LIABLE FOR ANY CLAIM, +DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE MODEL MATERIALS, THE +USE THEREOF OR OTHER DEALINGS THEREIN. diff --git a/tests/python/packaging/pyproject.toml b/tests/python/packaging/pyproject.toml index d923d3659c..7ebe555168 100644 --- a/tests/python/packaging/pyproject.toml +++ b/tests/python/packaging/pyproject.toml @@ -8,7 +8,7 @@ description = "Assertions over the wheel this build produced" requires-python = ">=3.11,<3.14" [project.optional-dependencies] -dev = ["pytest"] +dev = ["pytest", "reuse==6.2.0"] [tool.pytest.ini_options] pythonpath = ["."] diff --git a/tests/python/packaging/test_license_policy.py b/tests/python/packaging/test_license_policy.py new file mode 100644 index 0000000000..d8cad15f69 --- /dev/null +++ b/tests/python/packaging/test_license_policy.py @@ -0,0 +1,166 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Exercise licensing checks on real temporary REUSE projects.""" + +import runpy +import shutil +from pathlib import Path + +import pytest +from repo_paths import repo_root +from reuse.project import Project + +CHECK = runpy.run_path(str(repo_root() / "scripts/check_licenses.py")) +SUBTREE = Path("src/python/isaaccapture_egopost") +# REUSE-IgnoreStart +COPYRIGHT = "SPDX-FileCopyrightText: Copyright (c) 2020 NVIDIA CORPORATION & AFFILIATES. All rights reserved." + + +@pytest.fixture +def project(tmp_path, monkeypatch): + shutil.copytree(repo_root() / "LICENSES", tmp_path / "LICENSES") + (tmp_path / SUBTREE).mkdir(parents=True) + (tmp_path / "REUSE.toml").write_text( + "version = 1\n[[annotations]]\n" + f'path = "{SUBTREE}/**"\nprecedence = "closest"\n' + 'SPDX-License-Identifier = "LicenseRef-OpenMDW-1.1"\n' + ) + monkeypatch.chdir(tmp_path) + return tmp_path + + +@pytest.mark.parametrize("license_id", ["Apache-2.0", "Not-A-License"]) +def test_incorrect_egopost_license_fails(project, license_id): + (project / SUBTREE / "module.py").write_text( + f"# {COPYRIGHT}\n# SPDX-License-Identifier: {license_id}\n" + ) + assert CHECK["main"]() == 1 + assert CHECK["main"](fix=True) == 1 + if license_id == "Apache-2.0": + assert CHECK["main"](fix=True) == 0 + content = (project / SUBTREE / "module.py").read_text() + assert COPYRIGHT in content + assert "SPDX-License-Identifier: LicenseRef-OpenMDW-1.1" in content + + +def test_unannotated_first_party_asset_gets_sidecar(project): + path = project / SUBTREE / "config.json" + path.write_text("{}\n") + assert CHECK["main"]() == 1 + assert CHECK["main"](fix=True) == 1 + assert path.read_text() == "{}\n" + sidecar = path.with_suffix(".json.license").read_text() + assert "NVIDIA CORPORATION & AFFILIATES" in sidecar + assert "LicenseRef-OpenMDW-1.1" in sidecar + assert CHECK["main"](fix=True) == 0 + + +def test_unannotated_dependency_requires_review(project): + path = project / "deps" / "third_party" / "vendor.py" + path.parent.mkdir(parents=True) + path.write_text("value = 1\n") + assert CHECK["main"](fix=True) == 1 + assert path.read_text() == "value = 1\n" + + +def test_third_party_header_and_asset_sidecar_survive(project): + module = SUBTREE / "vendor.py" + source = ( + "# SPDX-FileCopyrightText: 2018 Original Author\n" + "# SPDX-License-Identifier: MIT\n" + ) + (project / module).write_text(source) + (project / SUBTREE / "config.json").write_text("{}\n") + (project / SUBTREE / "config.json.license").write_text( + "SPDX-FileCopyrightText: 2018 Original Author\nSPDX-License-Identifier: MIT\n" + ) + assert CHECK["main"](fix=True) == 0 + infos = Project.from_directory(project).reuse_info_of(module) + assert {str(e) for info in infos for e in info.spdx_expressions} == {"MIT"} + assert (project / module).read_text() == source + + +@pytest.mark.parametrize("license_id", ["Apache-2.0", "LicenseRef-Missing"]) +def test_metadata_only_change_rechecks_assets(project, license_id): + (project / SUBTREE / "config.json").write_text("{}\n") + metadata = project / "REUSE.toml" + metadata.write_text( + metadata.read_text() + + 'SPDX-FileCopyrightText = "2020 NVIDIA CORPORATION & AFFILIATES"\n' + ) + assert CHECK["main"]() == 0 + metadata.write_text( + metadata.read_text().replace("LicenseRef-OpenMDW-1.1", license_id) + ) + assert CHECK["main"]() == 1 + + +@pytest.mark.parametrize("source", ["sidecar", "toml"]) +def test_explicit_first_party_exception_survives(project, source): + path = project / SUBTREE / "module.py" + path.write_text(f"# {COPYRIGHT}\n# SPDX-License-Identifier: Apache-2.0\n") + if source == "sidecar": + path.with_suffix(".py.license").write_text( + f"{COPYRIGHT}\nSPDX-License-Identifier: Apache-2.0\n" + ) + else: + metadata = project / "REUSE.toml" + metadata.write_text( + metadata.read_text() + + ( + f'\n[[annotations]]\npath = "{SUBTREE}/module.py"\n' + 'precedence = "override"\n' + f'{COPYRIGHT.split(": ", 1)[0]} = "2020 NVIDIA CORPORATION & AFFILIATES"\n' + 'SPDX-License-Identifier = "Apache-2.0"\n' + ) + ) + assert CHECK["main"](fix=True) == 0 + + +def test_missing_default_tag_is_added_without_changing_copyright(project): + path = project / "module.py" + path.write_text(f"# {COPYRIGHT}\n\nvalue = 1\n") + before = Project.from_directory(project).reuse_info_of(path)[0].copyright_notices + assert CHECK["main"](fix=True) == 1 + content = path.read_text() + after = Project.from_directory(project).reuse_info_of(path)[0].copyright_notices + assert after == before + assert "SPDX-License-Identifier: Apache-2.0" in content + assert "value = 1" in content + assert CHECK["main"](fix=True) == 0 + + +def test_unannotated_core_file_gets_apache_header(project): + path = project / "module.py" + path.write_text("value = 1\n") + assert CHECK["main"](fix=True) == 1 + content = path.read_text() + assert "NVIDIA CORPORATION & AFFILIATES" in content + assert "SPDX-License-Identifier: Apache-2.0" in content + assert CHECK["main"](fix=True) == 0 + + +def test_copyright_only_header_uses_validated_egopost_default(project): + path = project / SUBTREE / "module.py" + path.write_text(f"# {COPYRIGHT}\nvalue = 1\n") + assert CHECK["first_party_licenses"]( + Project.from_directory(project), path.relative_to(project) + ) == {"LicenseRef-OpenMDW-1.1"} + assert CHECK["main"]() == 0 + metadata = project / "REUSE.toml" + metadata.write_text( + metadata.read_text().replace("LicenseRef-OpenMDW-1.1", "Apache-2.0") + ) + assert ( + CHECK["check_policy"]( + Project.from_directory(project), path.relative_to(project) + ) + is False + ) + assert CHECK["main"]() == 1 + assert CHECK["main"](fix=True) == 1 + assert path.read_text() == f"# {COPYRIGHT}\nvalue = 1\n" + + +# REUSE-IgnoreEnd diff --git a/tests/python/packaging/test_wheel_contents.py b/tests/python/packaging/test_wheel_contents.py index d564cdeab5..2d3e7eae4a 100644 --- a/tests/python/packaging/test_wheel_contents.py +++ b/tests/python/packaging/test_wheel_contents.py @@ -121,3 +121,16 @@ def test_spacemouse_plugin_ships_runnable_for_teleop_session() -> None: assert (info.external_attr >> 16) & 0o111, ( f"{info.filename} lost its executable bit" ) + + +def test_capture_license_ships_in_distribution_metadata() -> None: + with zipfile.ZipFile(_wheel()) as archive: + metadata_path = next( + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ) + metadata = BytesParser().parsebytes(archive.read(metadata_path)) + assert metadata["License-Expression"] == "Apache-2.0" + assert "LICENSE.md" in metadata.get_all("License-File", []) + license_path = metadata_path.removesuffix("METADATA") + "licenses/LICENSE.md" + assert archive.read(license_path) == (repo_root() / "LICENSE.md").read_bytes() + assert not any("isaaccapture_egopost/" in name for name in archive.namelist())