diff --git a/docs/requirements.txt b/docs/requirements.txt index d591c34a63..ebda86fc35 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -121,6 +121,7 @@ typing_extensions==4.11.0 uc-micro-py==1.0.3 urllib3==1.26.19 uvicorn==0.29.0 +validators==0.28.3 wadllib==1.3.6 watchfiles==0.21.0 wcmatch==8.5.1 diff --git a/requirements-devel.txt b/requirements-devel.txt index d42d2069c1..c20066a4bf 100644 --- a/requirements-devel.txt +++ b/requirements-devel.txt @@ -114,6 +114,7 @@ types-toml==0.10.8.20240310 types-urllib3==1.26.25.14 typing_extensions==4.9.0 urllib3==1.26.19 +validators==0.28.3 venusian==3.1.0 virtualenv==20.25.1 wadllib==1.3.6 diff --git a/requirements.txt b/requirements.txt index af830482b7..c75faaeb21 100644 --- a/requirements.txt +++ b/requirements.txt @@ -68,6 +68,7 @@ types-Deprecated==1.2.9.20240311 types-PyYAML==6.0.12.20240311 typing_extensions==4.9.0 urllib3==1.26.19 +validators==0.28.3 wadllib==1.3.6 wrapt==1.16.0 ws4py==0.5.1 diff --git a/snapcraft/meta/appstream.py b/snapcraft/meta/appstream.py index 60d500acb6..abd91caf2e 100644 --- a/snapcraft/meta/appstream.py +++ b/snapcraft/meta/appstream.py @@ -23,6 +23,8 @@ from typing import List, Optional, cast import lxml.etree +import validators +from craft_cli import emit from xdg.DesktopEntry import DesktopEntry from snapcraft import errors @@ -102,7 +104,15 @@ def extract(relpath: str, *, workdir: str) -> Optional[ExtractedMetadata]: version = _get_latest_release_from_nodes(dom.findall("releases/release")) project_license = _get_value_from_xml_element(dom, "project_license") update_contact = _get_value_from_xml_element(dom, "update_contact") - contact = [update_contact] if update_contact else None + contact = None + if update_contact: + if validators.url(update_contact) or validators.email(update_contact): + contact = [update_contact] + else: + emit.progress( + f"Ignoring invalid url {update_contact!r} in update_contact from appstream metadata.", + permanent=True, + ) issues = _get_urls_from_xml_element(dom.findall("url"), "bugtracker") donation = _get_urls_from_xml_element(dom.findall("url"), "donation") @@ -184,7 +194,14 @@ def _get_urls_from_xml_element(nodes, url_type) -> Optional[List[str]]: and node.attrib["type"] == url_type and node.text.strip() not in urls ): - urls.append(node.text.strip()) + link = node.text.strip() + if validators.url(link) or validators.email(link): + urls.append(link) + else: + emit.progress( + f"Ignoring invalid url or email {link!r} in {url_type!r} from appstream metadata.", + permanent=True, + ) if urls: return urls return None diff --git a/snapcraft/parts/update_metadata.py b/snapcraft/parts/update_metadata.py index 0c04133e89..d2ae9d9e71 100644 --- a/snapcraft/parts/update_metadata.py +++ b/snapcraft/parts/update_metadata.py @@ -17,7 +17,7 @@ """External metadata helpers.""" from pathlib import Path -from typing import Dict, Final, List, cast +from typing import Dict, Final, List, OrderedDict, cast import pydantic from craft_application.models import ProjectTitle, SummaryStr, UniqueStrList, VersionStr @@ -115,18 +115,26 @@ def _update_project_links( :param project: The Project model to update. :param metadata_list: A list of parsed information from metadata files. """ - for metadata in metadata_list: - fields = ["contact", "donation", "source_code", "issues", "website"] - for field in fields: - metadata_field = getattr(metadata, field) - project_field = getattr(project, field) - if metadata_field and project_field and project_field != metadata_field: - project_list = list(project_field) - project_list.extend(set(metadata_field) - set(project_list)) - setattr(project, field, cast(UniqueStrList, project_list)) - - if not getattr(project, field): - setattr(project, field, cast(UniqueStrList, metadata_field)) + fields = ["contact", "donation", "source_code", "issues", "website"] + for field in fields: + project_field = getattr(project, field) + + # only update the project if the project has not defined the field + if not project_field: + + # values for a field from all metadata files + metadata_values: list[str] = list() + + # iterate through all metadata and create a set of values for the field + for metadata in metadata_list: + if metadata_field := getattr(metadata, field): + metadata_values = list( + OrderedDict.fromkeys(metadata_values + metadata_field) + ) + + # update project with all new values from the metadata + if metadata_values: + setattr(project, field, cast(UniqueStrList, metadata_values)) def _update_project_variables(project: Project, project_vars: Dict[str, str]): diff --git a/tests/unit/meta/test_appstream.py b/tests/unit/meta/test_appstream.py index 6bc64bd42b..a7d6bcfc17 100644 --- a/tests/unit/meta/test_appstream.py +++ b/tests/unit/meta/test_appstream.py @@ -50,22 +50,40 @@ class TestAppstreamData: ("id", {}, "common_id", "test-id", "test-id"), ("name", {}, "title", "test-title", "test-title"), ("project_license", {}, "license", "test-license", "test-license"), - ("update_contact", {}, "contact", "test-contact", ["test-contact"]), - ("url", {"type": "homepage"}, "website", "test-website", ["test-website"]), - ("url", {"type": "bugtracker"}, "issues", "test-issues", ["test-issues"]), + ( + "update_contact", + {}, + "contact", + "test-contact@test.com", + ["test-contact@test.com"], + ), + ( + "url", + {"type": "homepage"}, + "website", + "http://test-website.test", + ["http://test-website.test"], + ), + ( + "url", + {"type": "bugtracker"}, + "issues", + "http://test-issues.test", + ["http://test-issues.test"], + ), ( "url", {"type": "donation"}, "donation", - "test-donation", - ["test-donation"], + "http://test-donation.test", + ["http://test-donation.test"], ), ( "url", {"type": "vcs-browser"}, "source_code", - "test-source", - ["test-source"], + "test@test.com", + ["test@test.com"], ), ], ) @@ -678,6 +696,7 @@ def test_appstream_links(self):
  • Publica snaps en la tienda.
  • + https://hello.com https://johnfactotum.github.io/foliate/ https://github.com/johnfactotum/foliate https://github.com/johnfactotum/foliate/issues @@ -692,6 +711,7 @@ def test_appstream_links(self): metadata = appstream.extract(file_name, workdir=".") assert metadata is not None + assert metadata.contact == ["https://hello.com"] assert metadata.website == ["https://johnfactotum.github.io/foliate/"] assert metadata.issues == ["https://github.com/johnfactotum/foliate/issues"] assert metadata.donation == ["https://www.buymeacoffee.com/johnfactotum"] @@ -708,6 +728,7 @@ def test_appstream_url(self): GPL-3.0-or-later Drawing + rrroschan@gmail.com https://johnfactotum.github.io/foliate/ https://github.com/johnfactotum/foliate @@ -717,6 +738,7 @@ def test_appstream_url(self): metadata = appstream.extract(file_name, workdir=".") assert metadata is not None + assert metadata.contact == ["rrroschan@gmail.com"] assert metadata.source_code == ["https://github.com/johnfactotum/foliate"] def test_appstream_with_multiple_lists(self): @@ -749,6 +771,39 @@ def test_appstream_with_multiple_lists(self): assert metadata.website == ["https://johnfactotum.github.io/foliate/"] assert metadata.donation is None + def test_appstream_with_malformed_links(self): + file_name = "test.appdata.xml" + content = textwrap.dedent( + """\ + + + com.github.maoschanz.drawing + CC0-1.0 + GPL-3.0-or-later + + Drawing + bad_invalid_mail + https://hello.com + file://invalid.file.link + http://missing-dot-com + https://github.com/alainm23/planify/issues + https:/missing-slash.com + https://www.buymeacoffee.com/alainm23 + paypal.me/bad + rrroschan@gmail.com + malformed_invalid@mailcom + + """ + ) + Path(file_name).write_text(content) + metadata = appstream.extract(file_name, workdir=".") + assert metadata is not None + assert metadata.contact == ["rrroschan@gmail.com"] + assert metadata.donation == ["https://www.buymeacoffee.com/alainm23"] + assert metadata.website == ["https://hello.com"] + assert metadata.source_code == None + assert metadata.issues == ["https://github.com/alainm23/planify/issues"] + def test_appstream_parse_error(self): file_name = "snapcraft_legacy.appdata.xml" content = textwrap.dedent( diff --git a/tests/unit/parts/test_update_metadata.py b/tests/unit/parts/test_update_metadata.py index 0e85ba4f3e..9d208db701 100644 --- a/tests/unit/parts/test_update_metadata.py +++ b/tests/unit/parts/test_update_metadata.py @@ -73,10 +73,8 @@ def yaml_data(extra_args: Dict[str, Any]): "confinement": "strict", "license": "license", "contact": "contact1", - "donation": "donation1", "issues": "issues1", "website": "website1", - "source-code": "source-code", "parts": {}, **extra_args, } @@ -133,11 +131,11 @@ def test_update_project_metadata(project_yaml_data, appstream_file, new_dir): assert project.summary == "summary" # already set in project assert project.description == "description" # already set in project assert project.version == "0.1" # already set in project - assert project.contact == ["contact1", "contact2"] - assert project.issues == ["issues1", "issues2"] - assert project.donation == ["donation1", "donation2"] - assert project.website == ["website1", "website2"] - assert project.source_code == ["source-code", "vcs-browser"] # adopts from project + assert project.contact == ["contact1"] # already set in project + assert project.issues == ["issues1"] # already set in project + assert project.donation == ["donation2"] + assert project.website == ["website1"] # already set in project + assert project.source_code == ["vcs-browser"] assert project.icon == "assets/icon.png" assert project.apps["app3"].desktop == "assets/file.desktop" @@ -256,6 +254,7 @@ def test_update_project_metadata_multiple( metadata2 = ExtractedMetadata( summary="metadata summary", description="metadata description", + contact=["contact1"], website=["website1"], source_code=["source-code"], issues=["issues1", "issues3"], @@ -267,7 +266,7 @@ def test_update_project_metadata_multiple( metadata4 = ExtractedMetadata( summary="extra summary", description="extra description" ) - metadata5 = ExtractedMetadata(license="GPL-3.0", contact=["test@test.com"]) + metadata5 = ExtractedMetadata(license="GPL-3.0", contact=["contact2", "contact1"]) metadata6 = ExtractedMetadata( source_code=["source-code", "vcs-browser"], website=["website2"], @@ -296,7 +295,7 @@ def test_update_project_metadata_multiple( assert project.description == expected["description"] assert project.title == expected["title"] assert project.grade == expected["grade"] - assert project.contact == ["test@test.com"] + assert project.contact == ["contact1", "contact2"] assert project.license == "GPL-3.0" assert project.donation == ["donation1", "donation2"] assert project.source_code == ["source-code", "vcs-browser"] @@ -304,6 +303,47 @@ def test_update_project_metadata_multiple( assert project.website == ["website1", "website2"] +def test_update_project_metadata_overriding_appstream(new_dir): + yaml_data = { + "name": "my-project", + "base": "core22", + "confinement": "strict", + "adopt-info": "part", + "parts": {}, + "contact": "test@test.com", + "donation": "https://paypal.me/", + "issues": "https://test.com/issues", + "source-code": "https://test.com/source-code", + "website": "https://test.com/website", + } + project = Project(**yaml_data) + metadata = ExtractedMetadata( + version="4.5.6", + summary="metadata summary", + description="metadata description", + contact=["help@help.me"], + website=["https://example.com/website"], + source_code=["https://example.com/source-code"], + issues=["https://example.com/issues", "https://example.com/issues2"], + donation=["https://buyme.coffee", "https://github.com/sponsors"], + ) + prj_vars = {"version": "", "grade": ""} + update_project_metadata( + project, + project_vars=prj_vars, + metadata_list=[metadata], + assets_dir=new_dir, + prime_dir=new_dir, + ) + + assert project is not None + assert project.contact == ["test@test.com"] + assert project.donation == ["https://paypal.me/"] + assert project.issues == ["https://test.com/issues"] + assert project.source_code == ["https://test.com/source-code"] + assert project.website == ["https://test.com/website"] + + @pytest.mark.parametrize( "project_entries,icon_exists,asset_exists,expected_icon", [