-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Add license validation for repositories #5343
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
65c71a5
24b7110
b0318e6
2a0d92e
2ad6253
fe5e2eb
76b6347
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING | ||
|
|
||
| from ..utils.json import json_loads | ||
| from .base import ActionValidationBase, ValidationException | ||
|
|
||
| if TYPE_CHECKING: | ||
| from ..repositories.base import HacsRepository | ||
|
|
||
| # renovate: datasource=github-tags depName=spdx/license-list-data | ||
| SPDX_LICENSE_LIST_COMMIT = "c4a7237ec8f4654e867546f9f409749300f1bf4c" # v3.28.0 | ||
|
|
||
| SPDX_LICENSE_LIST_URL = ( | ||
| "https://raw.githubusercontent.com/spdx/license-list-data/" | ||
| f"{SPDX_LICENSE_LIST_COMMIT}/json/licenses.json" | ||
| ) | ||
|
|
||
|
|
||
| async def async_setup_validator(repository: HacsRepository) -> Validator: | ||
| """Set up this validator.""" | ||
| return Validator(repository=repository) | ||
|
|
||
|
|
||
| class Validator(ActionValidationBase): | ||
| """Validate the repository.""" | ||
|
|
||
| more_info = "https://hacs.xyz/docs/publish/include#check-license" | ||
| allow_fork = False | ||
|
|
||
| async def async_validate(self) -> None: | ||
| """Validate the repository.""" | ||
| if (license_info := self.repository.repository_object.attributes.get("license")) is None: | ||
| raise ValidationException("The repository has no license") | ||
|
|
||
| spdx_id = license_info.get("spdx_id") | ||
| if not spdx_id: | ||
| raise ValidationException("The repository license is missing an SPDX ID") | ||
| if spdx_id == "NOASSERTION": | ||
| raise ValidationException( | ||
| "The repository license could not be identified (SPDX: NOASSERTION)" | ||
| ) | ||
|
|
||
| result = await self.hacs.async_download_file(SPDX_LICENSE_LIST_URL, handle_rate_limit=True) | ||
| if result is None: | ||
| raise ValidationException("Could not fetch the SPDX license list") | ||
|
ludeeus marked this conversation as resolved.
|
||
|
|
||
| try: | ||
| licenses = json_loads(result).get("licenses", []) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same thing. Is this running for all repos or just one? Should we cache the json loading
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This validator is for the HACS Github action. |
||
| except Exception as err: | ||
| raise ValidationException("Could not parse the SPDX license list") from err | ||
|
|
||
| osi_approved = { | ||
| entry.get("licenseId") | ||
| for entry in licenses | ||
| if entry.get("isOsiApproved") and entry.get("licenseId") | ||
| } | ||
|
ludeeus marked this conversation as resolved.
|
||
|
|
||
| if spdx_id not in osi_approved: | ||
| raise ValidationException( | ||
| f"The repository does not have an OSI-approved license (detected: '{spdx_id}')" | ||
| ) | ||
|
|
||
| self.repository.logger.debug( | ||
| "The repository has an OSI-approved license: %s", license_info.get("name") | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| { | ||
| "licenseListVersion": "3.28.0", | ||
| "licenses": [ | ||
| { | ||
| "licenseId": "CC0-1.0", | ||
| "name": "Creative Commons Zero v1.0 Universal", | ||
| "isDeprecatedLicenseId": false, | ||
| "isOsiApproved": false | ||
| }, | ||
| { | ||
| "licenseId": "GPL-3.0", | ||
| "name": "GNU General Public License v3.0 only", | ||
| "isDeprecatedLicenseId": true, | ||
| "isOsiApproved": true | ||
| }, | ||
| { | ||
| "licenseId": "MIT", | ||
| "name": "MIT License", | ||
| "isDeprecatedLicenseId": false, | ||
| "isOsiApproved": true | ||
| } | ||
| ], | ||
| "releaseDate": "2025-05-30" | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_missing_spdx_id": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_no_license": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_non_osi_license": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1, | ||
| "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_osi_approved_license[GPL-3.0]": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1, | ||
| "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_osi_approved_license[MIT]": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1, | ||
| "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_repository_unrecognized_license": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "tests/validate/test_license.py::test_spdx_license_list_fetch_failure": { | ||
| "https://api.github.com/repos/hacs/integration": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/custom_components/hacs/manifest.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/contents/hacs.json": 1, | ||
| "https://api.github.com/repos/hacs/integration/git/trees/main": 1, | ||
| "https://api.github.com/repos/hacs/integration/releases": 1, | ||
| "https://raw.githubusercontent.com/spdx/license-list-data/c4a7237ec8f4654e867546f9f409749300f1bf4c/json/licenses.json": 1 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| from unittest.mock import MagicMock | ||
|
|
||
| import pytest | ||
|
|
||
| from custom_components.hacs.validate.license import SPDX_LICENSE_LIST_URL, Validator | ||
|
|
||
| from tests.common import MockedResponse, ResponseMocker | ||
|
|
||
|
|
||
| async def test_repository_no_license(repository): | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = {"license": None} | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert check.failed | ||
|
|
||
|
|
||
| async def test_repository_unrecognized_license(repository): | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = { | ||
| "license": {"key": "other", "name": "Other", "spdx_id": "NOASSERTION"}, | ||
| } | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert check.failed | ||
|
|
||
|
|
||
| async def test_repository_missing_spdx_id(repository): | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = { | ||
| "license": {"key": "other", "name": "Other"}, | ||
| } | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert check.failed | ||
|
|
||
|
|
||
| async def test_repository_non_osi_license(repository): | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = { | ||
| "license": { | ||
| "key": "cc0-1.0", | ||
| "name": "Creative Commons Zero v1.0 Universal", | ||
| "spdx_id": "CC0-1.0", | ||
| }, | ||
| } | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert check.failed | ||
|
|
||
|
|
||
| async def test_spdx_license_list_fetch_failure(repository, response_mocker: ResponseMocker): | ||
| response_mocker.add(SPDX_LICENSE_LIST_URL, MockedResponse(status=500)) | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = { | ||
| "license": {"key": "mit", "name": "MIT License", "spdx_id": "MIT"}, | ||
| } | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert check.failed | ||
|
|
||
|
|
||
| @pytest.mark.parametrize("spdx_id", ["MIT", "GPL-3.0"]) | ||
| async def test_repository_osi_approved_license(repository, spdx_id): | ||
| repository.repository_object = MagicMock() | ||
| repository.repository_object.attributes = { | ||
| "license": { | ||
| "key": spdx_id.lower(), | ||
| "name": f"License {spdx_id}", | ||
| "spdx_id": spdx_id, | ||
| }, | ||
| } | ||
| check = Validator(repository) | ||
| await check.execute_validation() | ||
| assert not check.failed |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this cached?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not currently, but we can store a set of IDs.