diff --git a/ecosystem/validation/test_url.py b/ecosystem/validation/test_url.py index b7a9210072..7f36f6ad91 100644 --- a/ecosystem/validation/test_url.py +++ b/ecosystem/validation/test_url.py @@ -12,6 +12,8 @@ """URL Validations""" +from urllib.parse import urlparse + import pytest # pylint: disable=missing-function-docstring, missing-class-docstring @@ -20,7 +22,7 @@ class TestURLs: @classmethod def get_all_urls(cls, member): - """recursevly search for URLs in the member data""" + """recursively search for URLs in the member data""" for key, value in member.to_dict().items(): if isinstance(value, str) and value.startswith("http"): yield getattr(member, key) @@ -29,19 +31,63 @@ def get_all_urls(cls, member): else: continue + @staticmethod + def normalize_url(url): + """Normalize URL for comparison.""" + + if url is None: + return None, None + + parsed = urlparse(str(url)) + + hostname = (parsed.hostname or "").lower() + + if hostname.startswith("www."): + hostname = hostname[4:] + + path = parsed.path.rstrip("/") + + return hostname, path + + @staticmethod + def is_same_or_subpath(candidate, reference): + """ + Check whether candidate duplicates or points inside reference. + """ + + candidate_host, candidate_path = TestURLs.normalize_url(candidate) + + reference_host, reference_path = TestURLs.normalize_url(reference) + + if candidate_host != reference_host: + return False + + return candidate_path == reference_path or candidate_path.startswith( + reference_path + "/" + ) + def test_http(self, member): for url in TestURLs.get_all_urls(member): assert not str(url).startswith("http:"), f"{url} is not HTTPS" def test_025(self, member): """Documentation link has redundant suffix""" + fields = ["url", "documentation", "reference_paper"] + for field in fields: url = getattr(member, field) + if url is None: continue - if url.hostname.endswith("readthedocs.io"): - suffixes = ["en/latest/", "en/latest", "en"] + + if url.hostname.lower().endswith("readthedocs.io"): + suffixes = [ + "en/latest/", + "en/latest", + "en", + ] + for suffix in suffixes: assert not url.path.endswith( suffix @@ -49,13 +95,50 @@ def test_025(self, member): def test_026(self, member): """The /README.md is not documentation""" + documentation_url = getattr(member, "documentation") + if documentation_url is None: pytest.skip("No member.documentation") - if documentation_url.hostname.endswith("github.com"): - suffixes = ["main/README.md"] + + if documentation_url.hostname.lower().endswith("github.com"): + suffixes = [ + "main/README.md", + "blob/main/README.md", + "tree/main", + ] + for suffix in suffixes: assert not documentation_url.path.endswith(suffix), ( - "The field `member.documentation` can be empty. " - "It does not have to be a link to the `README.md`." + "The field `member.documentation` can be " + "empty. It does not have to be a link to " + "the repository's `README.md` or root." ) + + def test_027(self, member): + """Website should not duplicate GitHub or PyPI URLs""" + + website_url = getattr(member, "website") + + if website_url is None: + pytest.skip("No member.website") + + hostname = website_url.hostname or "" + + # Reject GitHub repository URLs as website + repository_url = getattr(member, "url") + + if repository_url is not None and repository_url.hostname.lower().endswith( + "github.com" + ): + assert not TestURLs.is_same_or_subpath( + website_url, + repository_url, + ), ( + "The field `member.website` should not " + "duplicate the GitHub repository URL." + ) + + assert not ( + hostname.endswith("pypi.org") or hostname.endswith("pypi.python.org") + ), ("The field `member.website` should not " "point to a PyPI project URL.") diff --git a/resources/checks.toml b/resources/checks.toml index bd1484807d..24ef2add3b 100644 --- a/resources/checks.toml +++ b/resources/checks.toml @@ -154,6 +154,15 @@ checker = "test_url.py::TestURLs::test_026" category = "SUBMISSION" importance = "RECOMMENDATION" +[027] +title = "Website should not be GitHub repository or PyPI URL" +description = "The `member.website` field should point to the project's website instead of duplicating the GitHub repository or a PyPI project page." +applies_to = "all" +affects = ["member.website"] +checker = "test_url.py::TestURLs::test_027" +category = "SUBMISSION" +importance = "RECOMMENDATION" + [G05] title = "Archived GitHub repository should have `archived` support" applies_to = "GitHub projects"