Repository: brettcannon/mousebender

Issue:
Add type hints
`NewType` might be neat to use, but that also assumes the values don't leave the package since it might require users to cast things.

https://docs.python.org/3/library/typing.html#distinct
https://mypy.readthedocs.io/en/stable/more_types.html#newtypes

Comments:

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 80b124b..ce66456 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,5 +4,6 @@
         "tests"
     ],
     "python.formatting.provider": "black",
-    "python.linting.flake8Enabled": true
+    "python.linting.flake8Enabled": true,
+    "python.linting.enabled": true
 }
diff --git a/mousebender/simple.py b/mousebender/simple.py
index e926b46..1308a1d 100644
--- a/mousebender/simple.py
+++ b/mousebender/simple.py
@@ -1,16 +1,15 @@
 """Parsing for PEP 503 -- Simple Repository API."""
 import html
 import html.parser
-import re
 import urllib.parse
 import warnings
 
-from typing import List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple
 
 import attr
 import packaging.specifiers
+import packaging.utils
 
-_NORMALIZE_RE = re.compile(r"[-_.]+")
 
 PYPI_INDEX = "https://pypi.org/simple/"
 
@@ -32,12 +31,10 @@ class UnsupportedVersionWarning(Warning, UnsupportedVersion):
     """
 
 
-def create_project_url(base_url, project_name):
+def create_project_url(base_url: str, project_name: str) -> str:
     """Construct the project URL for a repository following PEP 503."""
     if base_url and not base_url.endswith("/"):
-        base_url += "/"
-    # https://www.python.org/dev/peps/pep-0503/#normalized-names
-    normalized_project_name = _NORMALIZE_RE.sub("-", project_name).lower()
+        base_url += "/"  # Normalize for easier use w/ str.join() later.
     # PEP 503:
     # The format of this URL is /<project>/ where the <project> is replaced by
     # the normalized name for that project, so a project named "HolyGrail" would
@@ -45,7 +42,7 @@ def create_project_url(base_url, project_name):
     #
     # All URLs which respond with an HTML5 page MUST end with a / and the
     # repository SHOULD redirect the URLs without a / to add a / to the end.
-    return "".join([base_url, normalized_project_name, "/"])
+    return "".join([base_url, packaging.utils.canonicalize_name(project_name), "/"])
 
 
 def _normalize_project_url(url):
@@ -118,26 +115,55 @@ class _SimpleIndexHTMLParser(html.parser.HTMLParser):
             self._name = data
 
 
-def parse_repo_index(html):
+def parse_repo_index(html: str) -> Dict[str, str]:
     """Parse the HTML of a repository index page."""
     parser = _SimpleIndexHTMLParser()
     parser.feed(html)
     return parser.mapping
 
 
-@attr.frozen
+@attr.frozen(kw_only=True)
 class ArchiveLink:
 
     """Data related to a link to an archive file."""
 
     filename: str
     url: str
-    requires_python: packaging.specifiers.SpecifierSet
+    requires_python: packaging.specifiers.SpecifierSet = (
+        packaging.specifiers.SpecifierSet("")
+    )
     hash_: Optional[Tuple[str, str]] = None
     gpg_sig: Optional[bool] = None
-    yanked: Tuple[bool, str] = (False, "")
+    yanked: Optional[str] = None  # Is `""` if no message provided.
     metadata: Optional[Tuple[str, str]] = None  # No hash leads to a `("", "")` tuple.
 
+    def __str__(self) -> str:
+        attrs = []
+        if self.requires_python:
+            requires_str = str(self.requires_python)
+            escaped_requires = html.escape(requires_str)
+            attrs.append(f'data-requires-python="{escaped_requires}"')
+        if self.gpg_sig is not None:
+            attrs.append(f"data-gpg-sig={str(self.gpg_sig).lower()}")
+        if self.yanked is not None:
+            if self.yanked:
+                attrs.append(f'data-yanked="{self.yanked}"')
+            else:
+                attrs.append("data-yanked")
+        if self.metadata:
+            hash_algorithm, hash_value = self.metadata
+            if hash_algorithm:
+                attrs.append(f'data-dist-info-metadata="{hash_algorithm}={hash_value}"')
+            else:
+                attrs.append("data-dist-info-metadata")
+
+        url = self.url
+        if self.hash_:
+            hash_algorithm, hash_value = self.hash_
+            url += f"#{hash_algorithm}={hash_value}"
+
+        return f'<a href="{url}" {" ".join(attrs)}>{self.filename}</a>'
+
 
 class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
     def __init__(self):
@@ -160,34 +186,36 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         _, _, raw_filename = parsed_url.path.rpartition("/")
         filename = urllib.parse.unquote(raw_filename)
         url = urllib.parse.urlunparse((*parsed_url[:5], ""))
-        hash_ = None
+        args: Dict[str, Any] = {"filename": filename, "url": url}
         # PEP 503:
         # The URL SHOULD include a hash in the form of a URL fragment with the
         # following syntax: #<hashname>=<hashvalue> ...
         if parsed_url.fragment:
             hash_algo, hash_value = parsed_url.fragment.split("=", 1)
-            hash_ = hash_algo.lower(), hash_value
+            args["hash_"] = hash_algo.lower(), hash_value
         # PEP 503:
         # A repository MAY include a data-requires-python attribute on a file
         # link. This exposes the Requires-Python metadata field ...
         # In the attribute value, < and > have to be HTML encoded as &lt; and
         # &gt;, respectively.
-        requires_python_data = html.unescape(attrs.get("data-requires-python", ""))
-        requires_python = packaging.specifiers.SpecifierSet(requires_python_data)
+        if "data-requires-python" in attrs:
+            requires_python_data = html.unescape(attrs["data-requires-python"])
+            args["requires_python"] = packaging.specifiers.SpecifierSet(
+                requires_python_data
+            )
         # PEP 503:
         # A repository MAY include a data-gpg-sig attribute on a file link with
         # a value of either true or false ...
-        gpg_sig = attrs.get("data-gpg-sig")
-        if gpg_sig:
-            gpg_sig = gpg_sig == "true"
+        if "data-gpg-sig" in attrs:
+            args["gpg_sig"] = attrs["data-gpg-sig"] == "true"
         # PEP 592:
         # Links in the simple repository MAY have a data-yanked attribute which
         # may have no value, or may have an arbitrary string as a value.
-        yanked = "data-yanked" in attrs, attrs.get("data-yanked") or ""
+        if "data-yanked" in attrs:
+            args["yanked"] = attrs.get("data-yanked") or ""
         # PEP 658:
         # ... each anchor tag pointing to a distribution MAY have a
         # data-dist-info-metadata attribute.
-        metadata = None
         if "data-dist-info-metadata" in attrs:
             metadata = attrs.get("data-dist-info-metadata")
             if metadata and metadata != "true":
@@ -202,12 +230,9 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
                 # The repository MAY use true as the attribute's value if a hash
                 # is unavailable.
                 metadata = "", ""
+            args["metadata"] = metadata
 
-        self.archive_links.append(
-            ArchiveLink(
-                filename, url, requires_python, hash_, gpg_sig, yanked, metadata
-            )
-        )
+        self.archive_links.append(ArchiveLink(**args))
 
 
 def parse_archive_links(html: str) -> List[ArchiveLink]:


Test patch:
diff --git a/tests/test_simple.py b/tests/test_simple.py
index d9e53f4..2f403aa 100644
--- a/tests/test_simple.py
+++ b/tests/test_simple.py
@@ -105,6 +105,63 @@ class TestRepoIndexParsing:
         assert index["django-node"] == "django-node/"
 
 
+class TestArchiveLink:
+
+    """Tests for mousebender.simple.ArchiveLink."""
+
+    @pytest.mark.parametrize(
+        "archive_link",
+        [
+            simple.ArchiveLink(filename="B", url="A/B"),
+            simple.ArchiveLink(
+                filename="B",
+                url="A/B",
+                requires_python=packaging.specifiers.SpecifierSet(">=3.6"),
+            ),
+            simple.ArchiveLink(
+                filename="B",
+                url="A/B",
+                hash_=("sha256", "ABCDEF"),
+            ),
+            simple.ArchiveLink(
+                filename="B",
+                url="A/B",
+                gpg_sig=True,
+            ),
+            simple.ArchiveLink(filename="B", url="A/B", yanked=""),
+            simple.ArchiveLink(filename="B", url="A/B", yanked="oops!"),
+            simple.ArchiveLink(filename="B", url="A/B", metadata=("", "")),
+            simple.ArchiveLink(filename="B", url="A/B", metadata=("sha256", "ABCDEF")),
+            simple.ArchiveLink(
+                filename="B",
+                url="A/B",
+                requires_python=packaging.specifiers.SpecifierSet(">=3.6"),
+                hash_=("sha256", "ABCDEF"),
+                gpg_sig=True,
+                yanked="oops!",
+                metadata=("sha512", "GHIJKL"),
+            ),
+        ],
+    )
+    def test_str(self, archive_link):
+        """Make sure __str__ roundtrips."""
+        html = str(archive_link)
+        roundtrip = simple.parse_archive_links(html)
+        assert len(roundtrip) == 1
+        print(html)
+        print(roundtrip[0])
+        assert archive_link == roundtrip[0]
+
+    def test_str_escaping(self):
+        """data-requires-python must have an escaped value."""
+        archive_link = simple.ArchiveLink(
+            filename="B",
+            url="A/B",
+            requires_python=packaging.specifiers.SpecifierSet(">=3.6"),
+        )
+        assert "gt;=3.6" in str(archive_link)
+
+
 class TestParseArchiveLinks:
 
     """Tests for mousebender.simple.parse_archive_links()."""
@@ -116,66 +173,76 @@ class TestParseArchiveLinks:
                 "numpy",
                 1402,
                 simple.ArchiveLink(
-                    "numpy-1.13.0rc1-cp36-none-win_amd64.whl",
-                    "https://files.pythonhosted.org/packages/5c/2e/5c0eee0635035a7e0646734e2b9388e17a97f6f2087e15141a218b6f2b6d/numpy-1.13.0rc1-cp36-none-win_amd64.whl",
-                    packaging.specifiers.SpecifierSet(
+                    filename="numpy-1.13.0rc1-cp36-none-win_amd64.whl",
+                    url="https://files.pythonhosted.org/packages/5c/2e/5c0eee0635035a7e0646734e2b9388e17a97f6f2087e15141a218b6f2b6d/numpy-1.13.0rc1-cp36-none-win_amd64.whl",
+                    requires_python=packaging.specifiers.SpecifierSet(
                         ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*"
                     ),
-                    (
+                    hash_=(
                         "sha256",
                         "8e8e1ccf025c8b6a821f75086a364a68d9e1877519a35bf8facec9e5120836f4",
                     ),
-                    None,
+                    gpg_sig=None,
+                    yanked=None,
+                    metadata=None,
                 ),
             ),
             (
                 "pulpcore-client",
                 370,
                 simple.ArchiveLink(
-                    "pulpcore_client-3.1.0.dev1578940535-py3-none-any.whl",
-                    "https://files.pythonhosted.org/packages/ca/7e/e14e41dc4bc60208f597f346d57755636e882be7509179c4e7c11f2c60a9/pulpcore_client-3.1.0.dev1578940535-py3-none-any.whl",
-                    packaging.specifiers.SpecifierSet(),
-                    (
+                    filename="pulpcore_client-3.1.0.dev1578940535-py3-none-any.whl",
+                    url="https://files.pythonhosted.org/packages/ca/7e/e14e41dc4bc60208f597f346d57755636e882be7509179c4e7c11f2c60a9/pulpcore_client-3.1.0.dev1578940535-py3-none-any.whl",
+                    requires_python=packaging.specifiers.SpecifierSet(),
+                    hash_=(
                         "sha256",
                         "83a3759d7b6af33083b0d4893d53615fc045cbad9adde68a8df02e25b1862bc6",
                     ),
-                    None,
+                    gpg_sig=None,
+                    yanked=None,
+                    metadata=None,
                 ),
             ),
             (
                 "pytorch",
                 522,
                 simple.ArchiveLink(
-                    "torchvision-0.5.0+cu100-cp36-cp36m-linux_x86_64.whl",
-                    "cu100/torchvision-0.5.0%2Bcu100-cp36-cp36m-linux_x86_64.whl",
-                    packaging.specifiers.SpecifierSet(),
-                    None,
-                    None,
+                    filename="torchvision-0.5.0+cu100-cp36-cp36m-linux_x86_64.whl",
+                    url="cu100/torchvision-0.5.0%2Bcu100-cp36-cp36m-linux_x86_64.whl",
+                    requires_python=packaging.specifiers.SpecifierSet(),
+                    hash_=None,
+                    gpg_sig=None,
+                    yanked=None,
+                    metadata=None,
                 ),
             ),
             (
                 "AICoE-tensorflow",
                 15,
                 simple.ArchiveLink(
-                    "tensorflow-2.0.0-cp37-cp37m-linux_x86_64.whl",
-                    "tensorflow-2.0.0-cp37-cp37m-linux_x86_64.whl",
-                    packaging.specifiers.SpecifierSet(),
-                    None,
-                    None,
+                    filename="tensorflow-2.0.0-cp37-cp37m-linux_x86_64.whl",
+                    url="tensorflow-2.0.0-cp37-cp37m-linux_x86_64.whl",
+                    requires_python=packaging.specifiers.SpecifierSet(),
+                    hash_=None,
+                    gpg_sig=None,
+                    yanked=None,
+                    metadata=None,
                 ),
             ),
             (
                 "numpy-piwheels",
                 316,
                 simple.ArchiveLink(
-                    "numpy-1.10.4-cp35-cp35m-linux_armv7l.whl",
-                    "numpy-1.10.4-cp35-cp35m-linux_armv7l.whl",
-                    packaging.specifiers.SpecifierSet(),
-                    (
+                    filename="numpy-1.10.4-cp35-cp35m-linux_armv7l.whl",
+                    url="numpy-1.10.4-cp35-cp35m-linux_armv7l.whl",
+                    requires_python=packaging.specifiers.SpecifierSet(),
+                    hash_=(
                         "sha256",
                         "5768279588a4766adb0211bbaa0f5857be38483c5aafe5d1caecbcd32749966e",
                     ),
-                    None,
+                    gpg_sig=None,
+                    yanked=None,
+                    metadata=None,
                 ),
             ),
         ],
@@ -298,19 +365,19 @@ class TestParseArchiveLinks:
         [
             (
                 '<a href="spam-1.2.3-py3.none.any.whl" data-yanked>spam-1.2.3-py3.none.any.whl</a>',
-                (True, ""),
+                "",
             ),
             (
                 '<a href="spam-1.2.3-py3.none.any.whl" data-yanked="oops!">spam-1.2.3-py3.none.any.whl</a>',
-                (True, "oops!"),
+                "oops!",
             ),
             (
                 '<a href="spam-1.2.3-py3.none.any.whl" data-yanked="">spam-1.2.3-py3.none.any.whl</a>',
-                (True, ""),
+                "",
             ),
             (
                 '<a href="spam-1.2.3-py3.none.any.whl">spam-1.2.3-py3.none.any.whl</a>',
-                (False, ""),
+                None,
             ),
         ],
     )