diff --git a/snapcraft/models/project.py b/snapcraft/models/project.py index bc7ae5d5a8..95ac883082 100644 --- a/snapcraft/models/project.py +++ b/snapcraft/models/project.py @@ -42,7 +42,12 @@ Grammar, ) from craft_platforms import DebianArchitecture -from pydantic import ConfigDict, PrivateAttr, StringConstraints, error_wrappers +from pydantic import ( + ConfigDict, + PrivateAttr, + StringConstraints, + error_wrappers, +) from pydantic.json_schema import ( SkipJsonSchema, # noqa: TC002 (typing-only-third-party-import) # pydantic needs to import types at runtime for validation ) diff --git a/snapcraft/services/project.py b/snapcraft/services/project.py index 7a8ecab13c..6b44647cd9 100644 --- a/snapcraft/services/project.py +++ b/snapcraft/services/project.py @@ -23,7 +23,9 @@ import craft_providers.bases from craft_application import ProjectService from craft_application.errors import CraftValidationError +from craft_application.models.constraints import LicenseStr from craft_application.util import is_managed_mode +from pydantic import TypeAdapter, ValidationError from typing_extensions import override from snapcraft.extensions import apply_extensions @@ -48,8 +50,9 @@ class Project(ProjectService): __project_file_path: pathlib.Path | None = None - # Used to only issue a warning once. + # Used to only issue key warnings once. _ua_service_warning: bool = False + _license_spdx_warning: bool = False @staticmethod @override @@ -65,10 +68,31 @@ def _app_preprocess_project( extract_parse_info(project) apply_root_packages(project) Project.validate_ua_services(project) + Project.validate_license_spdx(project) def get_parse_info(self) -> dict[str, list[str]]: return extract_parse_info(self.get_raw()) + @classmethod + def validate_license_spdx(cls, project: dict[str, Any]) -> None: + if cls._license_spdx_warning: + return + cls._license_spdx_warning = True + + if is_managed_mode(): + return + + lic = project.get("license") + if lic is None: + return + + try: + TypeAdapter(LicenseStr).validate_python(lic) + except ValidationError: + craft_cli.emit.warning( + "Non-SPDX licenses are deprecated. Use SPDX license strings or 'proprietary' instead. For more information, see https://spdx.org/licenses/." + ) + @classmethod def validate_ua_services(cls, project: dict[str, Any]) -> None: """Warn if the 'ua-services' key is used for a base other than core22. diff --git a/tests/unit/services/test_project.py b/tests/unit/services/test_project.py index a7976d177a..4435884836 100644 --- a/tests/unit/services/test_project.py +++ b/tests/unit/services/test_project.py @@ -19,10 +19,12 @@ import itertools import pathlib from typing import Any +from unittest.mock import call import pytest import pytest_mock from craft_application.errors import CraftValidationError +from craft_cli.pytest_plugin import RecordingEmitter from snapcraft import const from snapcraft.application import APP_METADATA @@ -30,11 +32,13 @@ @pytest.fixture(autouse=True) -def reset_ua_service_warning(): - """Reset the one-shot ua-service warning flag between tests.""" +def reset_warnings(): + """Reset the one-shot warning flags between tests.""" Project._ua_service_warning = False + Project._license_spdx_warning = False yield Project._ua_service_warning = False + Project._license_spdx_warning = False @pytest.mark.parametrize( @@ -158,3 +162,56 @@ def test_no_warning_for_core22(self, emitter): Project.validate_ua_services(project) emitter.assert_interactions(None) + + +class TestValidateLicense: + def test_non_spdx_deprecation_warns_once(self, emitter: RecordingEmitter) -> None: + project = {"license": "maybe"} + + Project.validate_license_spdx(project) + Project.validate_license_spdx(project) + + emitter.assert_warning( + "Non-SPDX licenses are deprecated. Use SPDX license strings or 'proprietary' instead. For more information, see https://spdx.org/licenses/." + ) + # assert it was only shown once + assert len(emitter.interactions) == 1 + + def test_no_warning_in_managed_mode( + self, emitter: RecordingEmitter, mocker: pytest_mock.MockerFixture + ): + """Don't warn in managed-mode.""" + mocker.patch("snapcraft.services.project.is_managed_mode", return_value=True) + project = {"license": "maybe"} + + Project.validate_ua_services(project) + + emitter.assert_interactions(None) + + @pytest.mark.parametrize( + ("lic", "should_warn"), + [ + ("MIT", False), + ("proprietary", False), + (None, False), + ("DemonicContract", True), + ], + ) + def test_non_spdx_deprecation( + self, + lic: str | None, + should_warn: bool, + emitter: RecordingEmitter, + ) -> None: + project = {"license": lic} + Project.validate_license_spdx(project) + + assert should_warn == ( + call( + "warning", + "Non-SPDX licenses are deprecated. Use SPDX license strings or 'proprietary' instead. For more information, see https://spdx.org/licenses/.", + ) + in emitter.interactions + ) + # License should always remain unchanged + assert project.get("license") == lic