Repository: brettcannon/mousebender

Issue:
Consider switching to Ruff from flake8
https://github.com/charliermarsh/ruff

https://marketplace.visualstudio.com/items?itemName=charliermarsh.ruff

Discussion:

Patch:
diff --git a/.flake8 b/.flake8
deleted file mode 100644
index fd1cee7..0000000
--- a/.flake8
+++ /dev/null
@@ -1,3 +0,0 @@
-[flake8]
-ignore = C,E,W
-exclude = .venv
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d6f16f7..3c3e6f3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -12,7 +12,7 @@ jobs:
 
       - name: Lint code
         run: |
-          pipx run flake8
+          pipx run nox -s check_code
 
       - name: Check format
         run: |
@@ -24,7 +24,7 @@ jobs:
 
       - name: Build wheel
         run: |
-          pipx run flit build
+          pipx run nox -s build
 
   tests:
     name: Test Python ${{ matrix.python_version }}
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
index f42d932..eb2311f 100644
--- a/.vscode/extensions.json
+++ b/.vscode/extensions.json
@@ -3,6 +3,6 @@
         "ms-python.python",
         "ms-python.black-formatter",
         "ms-python.isort",
-        "ms-python.flake8"
+        "charliermarsh.ruff"
     ]
 }
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 27234e4..6160836 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -1,7 +1,12 @@
 {
+    "[python]": {
+        "editor.defaultFormatter": "ms-python.black-formatter"
+    },
     "python.testing.pytestEnabled": true,
     "python.testing.pytestArgs": [
         "tests"
     ],
-    "editor.defaultFormatter": "ms-python.black-formatter"
-}
\ No newline at end of file
+    "ruff.logLevel": "warn",
+    "isort.check": true,
+    "isort.logLevel": "warn"
+}
diff --git a/mousebender/simple.py b/mousebender/simple.py
index 51310d9..f840732 100644
--- a/mousebender/simple.py
+++ b/mousebender/simple.py
@@ -1,9 +1,20 @@
+"""Implement the Simple Repository API.
+
+This encompasses PEPs:
+
+1. 503: Simple Repository API
+2. 592: Adding “Yank” Support to the Simple API
+3. 629: Versioning PyPI's Simple API
+4. 658: Serve Distribution Metadata in the Simple Repository API
+5. 691: JSON-based Simple API for Python Package Indexes
+
+"""
 from __future__ import annotations
 
 import html
 import html.parser
 import urllib.parse
-from typing import Dict, List, Union
+from typing import Any, Dict, List, Optional, Union
 
 import packaging.specifiers
 import packaging.utils
@@ -30,6 +41,8 @@ _Meta = TypedDict("_Meta", {"api-version": Literal["1.0"]})
 
 
 class ProjectIndex(TypedDict):
+    """A TypedDict representing a project index."""
+
     meta: _Meta
     projects: List[Dict[Literal["name"], str]]
 
@@ -48,41 +61,46 @@ _OptionalProjectFileDetails = TypedDict(
 
 
 class ProjectFileDetails(_OptionalProjectFileDetails):
+    """A TypedDict representing a project file's details."""
+
     filename: str
     url: str
     hashes: _HashesDict
 
 
 class ProjectDetails(TypedDict):
+    """A TypedDict representing a project's detail."""
+
     meta: _Meta
     name: packaging.utils.NormalizedName
     files: list[ProjectFileDetails]
 
 
 class _SimpleIndexHTMLParser(html.parser.HTMLParser):
-
     """Parse the HTML of a repository index page."""
 
     # PEP 503:
     # Within a repository, the root URL (/) MUST be a valid HTML5 page with a
     # single anchor element per project in the repository.
 
-    def __init__(self):
+    def __init__(self) -> None:
         super().__init__()
         self._parsing_anchor = False
-        self.names = []
+        self.names: List[str] = []
 
-    def handle_starttag(self, tag, _attrs_list):
+    def handle_starttag(
+        self, tag: str, _attrs_list: list[tuple[str, Optional[str]]]
+    ) -> None:
         if tag != "a":
             return
         self._parsing_anchor = True
 
-    def handle_endtag(self, tag):
+    def handle_endtag(self, tag: str) -> None:
         if tag != "a":
             return
         self._parsing_anchor = False
 
-    def handle_data(self, data):
+    def handle_data(self, data: str) -> None:
         if self._parsing_anchor:
             self.names.append(data)
 
@@ -99,18 +117,22 @@ def from_project_index_html(html: str) -> ProjectIndex:
 
 
 class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
-    def __init__(self):
-        self.archive_links = []
+    def __init__(self) -> None:
+        self.archive_links: List[Dict[str, Any]] = []
         super().__init__()
 
-    def handle_starttag(self, tag, attrs_list):
+    def handle_starttag(
+        self, tag: str, attrs_list: list[tuple[str, Optional[str]]]
+    ) -> None:
         attrs = dict(attrs_list)
         if tag != "a":
             return
         # PEP 503:
         # The href attribute MUST be a URL that links to the location of the
         # file for download ...
-        full_url = attrs["href"]
+        if "href" not in attrs or not attrs["href"]:
+            return
+        full_url: str = attrs["href"]
         parsed_url = urllib.parse.urlparse(full_url)
         # PEP 503:
         # ... the text of the anchor tag MUST match the final path component
@@ -118,7 +140,7 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         _, _, raw_filename = parsed_url.path.rpartition("/")
         filename = urllib.parse.unquote(raw_filename)
         url = urllib.parse.urlunparse((*parsed_url[:5], ""))
-        args = {"filename": filename, "url": url}
+        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> ...
@@ -130,7 +152,7 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         # link. This exposes the Requires-Python metadata field ...
         # In the attribute value, < and > have to be HTML encoded as &lt; and
         # &gt;, respectively.
-        if "data-requires-python" in attrs:
+        if "data-requires-python" in attrs and attrs["data-requires-python"]:
             requires_python_data = html.unescape(attrs["data-requires-python"])
             args["requires-python"] = requires_python_data
         # PEP 503:
@@ -147,15 +169,15 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
         # ... each anchor tag pointing to a distribution MAY have a
         # data-dist-info-metadata attribute.
         if "data-dist-info-metadata" in attrs:
-            metadata = attrs.get("data-dist-info-metadata")
-            if metadata and metadata != "true":
+            found_metadata = attrs.get("data-dist-info-metadata")
+            if found_metadata and found_metadata != "true":
                 # The repository SHOULD provide the hash of the Core Metadata
                 # file as the data-dist-info-metadata attribute's value using
                 # the syntax <hashname>=<hashvalue>, where <hashname> is the
                 # lower cased name of the hash function used, and <hashvalue> is
                 # the hex encoded digest.
-                algorithm, _, hash = metadata.partition("=")
-                metadata = (algorithm.lower(), hash)
+                algorithm, _, hash_ = found_metadata.partition("=")
+                metadata = (algorithm.lower(), hash_)
             else:
                 # The repository MAY use true as the attribute's value if a hash
                 # is unavailable.
@@ -166,6 +188,7 @@ class _ArchiveLinkHTMLParser(html.parser.HTMLParser):
 
 
 def from_project_details_html(name: str, html: str) -> ProjectDetails:
+    """Parse the HTML of a project details page."""
     parser = _ArchiveLinkHTMLParser()
     parser.feed(html)
     files: List[ProjectFileDetails] = []
diff --git a/noxfile.py b/noxfile.py
index 75c9857..d30143b 100644
--- a/noxfile.py
+++ b/noxfile.py
@@ -1,3 +1,8 @@
+"""Developer-related actions.
+
+All sessions prefixed with `check_` are non-destructive.
+
+"""
 import nox
 
 python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11"]
@@ -6,7 +11,10 @@ python_versions = ["3.7", "3.8", "3.9", "3.10", "3.11"]
 @nox.session(python=python_versions)
 def test(session, coverage=False):
     """Run the test suite."""
-    session.run("pytest", *(["--cov"] if coverage else []))
+    session.install("-e", ".[test]")
+    session.run(
+        "pytest", *(["--cov", "--cov-report", "term-missing"] if coverage else [])
+    )
 
 
 @nox.session(python=python_versions)
@@ -37,3 +45,17 @@ def format(session, check=False):
 def check_format(session):
     """Check that the code is properly formatted."""
     format(session, check=True)
+
+
+@nox.session
+def check_code(session):
+    """Lint the code."""
+    session.install("ruff")
+    session.run("ruff", "mousebender", "tests")
+
+
+@nox.session
+def build(session):
+    """Build the wheel and sdist."""
+    session.install("flit")
+    session.run("flit", "build")
diff --git a/pyproject.toml b/pyproject.toml
index 99e08eb..fedd832 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,12 +6,12 @@ build-backend = "flit_core.buildapi"
 [project]
 name = "mousebender"
 authors = [
-    {name = "Brett Cannon", email = "brett@python.org"},
-    {name = "Derek Keeler", email = "derek@suchcool.ca"}
+    { name = "Brett Cannon", email = "brett@python.org" },
+    { name = "Derek Keeler", email = "derek@suchcool.ca" },
 ]
 readme = "README.rst"
 requires-python = ">=3.6"
-license = {file = "LICENSE"}
+license = { file = "LICENSE" }
 keywords = ["packaging", "PEP 503", "PEP 592"]
 urls.homepage = "https://github.com/brettcannon/mousebender"
 urls.repository = "https://github.com/brettcannon/mousebender"
@@ -19,18 +19,15 @@ urls.issues = "https://github.com/brettcannon/mousebender/issues"
 urls.documentation = "https://github.com/brettcannon/mousebender/blob/master/README.rst"
 classifiers = [
     "Topic :: Software Development :: Libraries :: Python Modules",
-    "License :: OSI Approved :: BSD License"
+    "License :: OSI Approved :: BSD License",
 ]
 
-dependencies = [
-    "packaging >= 20.9",
-    "typing-extensions"
-]
+dependencies = ["packaging >= 20.9", "typing-extensions"]
 optional-dependencies.test = [
     "pytest >= 6.0.1",
     "importlib-resources >= 1.4.0",
     "coverage[toml] >= 5.0.4",
-    "pytest-cov >= 2.8.1"
+    "pytest-cov >= 2.8.1",
 ]
 
 dynamic = ["version", "description"]
@@ -43,3 +40,14 @@ report.fail_under = 100
 
 [tool.isort]
 profile = "black"
+
+[tool.ruff]
+select = ["E", "F", "W", "D", "C", "B", "A", "ANN", "RUF", "M"]
+ignore = ["E501", "D203", "D213", "ANN101"]
+per-file-ignores = { "tests/*" = [
+    "D",
+    "ANN",
+], "noxfile.py" = [
+    "ANN",
+    "A001",
+] }


Test patch:
diff --git a/tests/test_simple.py b/tests/test_simple.py
index 446144c..aa3357c 100644
--- a/tests/test_simple.py
+++ b/tests/test_simple.py
@@ -8,9 +8,6 @@ from .data import simple as simple_data
 
 
 class TestProjectURLConstruction:
-
-    """Tests for mousebender.simple.create_project_url()."""
-
     @pytest.mark.parametrize("base_url", ["/simple/", "/simple"])
     def test_url_joining(self, base_url):
         url = simple.create_project_url(base_url, "hello")
@@ -36,9 +33,6 @@ class TestProjectURLConstruction:
 
 
 class TestRepoIndexParsing:
-
-    """Tests for mousebender.simple.parse_repo_index()."""
-
     @pytest.mark.parametrize(
         "name,count,expected_item",
         [
@@ -75,9 +69,6 @@ class TestRepoIndexParsing:
 
 
 class TestProjectDetailsParsing:
-
-    """Tests for mousebender.simple. from project details HTML."""
-
     @pytest.mark.parametrize(
         "module_name,count,expected_file_details",
         [
@@ -183,6 +174,11 @@ class TestProjectDetailsParsing:
         assert len(project_details["files"]) == 1
         assert project_details["files"][0]["url"] == expected_url
 
+    def test_no_href(self):
+        html = "<a>numpy-1.12.1-cp35-none-win_amd64.whl</a><br/>"
+        project_details = simple.from_project_details_html("test_no_href", html)
+        assert not len(project_details["files"])
+
     @pytest.mark.parametrize(
         "html,expected",
         [