Repository: scientific-python/cookie

Issue:
configparser.NoSectionError: No section: 'options'
```console
$ git remote -v
upstream	https://github.com/wtclarke/spec2nii.git (fetch)
upstream	https://github.com/wtclarke/spec2nii.git (push)
$ 
$ pipx run 'sp-repo-review[cli]' .
╭─────────────────────────────────────────────── Traceback (most recent call last) ────────────────────────────────────────────────╮
.
.
.
│ /usr/lib/python3.10/configparser.py:1154 in _unify_values                                                                        │
│                                                                                                                                  │
│   1151 │   │   │   sectiondict = self._sections[section]                                                                         │
│   1152 │   │   except KeyError:                                                                                                  │
│   1153 │   │   │   if section != self.default_section:                                                                           │
│ ❱ 1154 │   │   │   │   raise NoSectionError(section) from None                                                                   │
│   1155 │   │   # Update with the entry specific variables                                                                        │
│   1156 │   │   vardict = {}                                                                                                      │
│   1157 │   │   if vars:                                                                                                          │
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
NoSectionError: No section: 'options'
$ 
```

Discussion:

Solution patch:
diff --git a/src/sp_repo_review/checks/pyproject.py b/src/sp_repo_review/checks/pyproject.py
index 8f6be23..b600915 100644
--- a/src/sp_repo_review/checks/pyproject.py
+++ b/src/sp_repo_review/checks/pyproject.py
@@ -1,10 +1,12 @@
 from __future__ import annotations
 
-from typing import Any
+from typing import TYPE_CHECKING, Any
 
-from .._compat.importlib.resources.abc import Traversable
 from . import mk_url
 
+if TYPE_CHECKING:
+    from configparser import ConfigParser
+
 
 class PyProject:
     family = "pyproject"
@@ -57,7 +59,7 @@ class PP004(PyProject):
     url = mk_url("packaging-simple")
 
     @staticmethod
-    def check(pyproject: dict[str, Any], package: Traversable) -> bool | None:
+    def check(pyproject: dict[str, Any], setupcfg: ConfigParser | None) -> bool | None:
         """
         You should never upper cap your Python requirement. This is rarely correct, and
         tools like pip do not handle this properly even if it is correct. This field is used
@@ -81,15 +83,10 @@ class PP004(PyProject):
                     "^" not in requires and "~=" not in requires and "<" not in requires
                 )
 
-        setup_cfg = package / "setup.cfg"
-        if setup_cfg.is_file():
-            # pylint: disable-next=import-outside-toplevel
-            import configparser
-
-            config = configparser.ConfigParser()
-            config.read_string(setup_cfg.read_text(encoding="utf-8"))
-            if requires := config.get("options", "python_requires"):
-                return "~=" not in requires and "<" not in requires
+        if setupcfg and (
+            requires := setupcfg.get("options", "python_requires", fallback=None)
+        ):
+            return "~=" not in requires and "<" not in requires
 
         return None
 


Test patch:
diff --git a/tests/test_pyproject.py b/tests/test_pyproject.py
index abf6503..f2be9fd 100644
--- a/tests/test_pyproject.py
+++ b/tests/test_pyproject.py
@@ -1,5 +1,5 @@
+import configparser
 import inspect
-from pathlib import Path
 
 from repo_review.testing import compute_check, toml_loads
 
@@ -49,68 +49,88 @@ def test_PP003_has_wheel():
     assert not compute_check("PP003", pyproject=toml).result
 
 
-def test_PP004_no_cap_pyproject(tmp_path: Path):
+def test_PP004_no_cap_pyproject():
     toml = toml_loads("""
        [project]
        requires-python = ">=3.10"
        """)
 
-    assert compute_check("PP004", pyproject=toml, package=tmp_path).result
+    assert compute_check("PP004", pyproject=toml, setupcfg=None).result
 
 
-def test_PP004_cap_pyproject(tmp_path: Path):
+def test_PP004_cap_pyproject():
     toml = toml_loads("""
         [project]
         requires-python = ">=3.10, <4"
         """)
 
-    assert compute_check("PP004", pyproject=toml, package=tmp_path).result is False
+    assert compute_check("PP004", pyproject=toml, setupcfg=None).result is False
 
 
-def test_PP004_cap_tilde_pyproject(tmp_path: Path):
+def test_PP004_cap_tilde_pyproject():
     toml = toml_loads("""
         [project]
         requires-python = "~=3.10"
         """)
 
-    assert compute_check("PP004", pyproject=toml, package=tmp_path).result is False
+    assert compute_check("PP004", pyproject=toml, setupcfg=None).result is False
 
 
-def test_PP004_cap_caret_pyproject(tmp_path: Path):
+def test_PP004_cap_caret_pyproject():
     toml = toml_loads("""
         [tool.poetry.dependencies]
         python = "^3.10"
        """)
 
-    assert compute_check("PP004", pyproject=toml, package=tmp_path).result is False
+    assert compute_check("PP004", pyproject=toml, setupcfg=None).result is False
 
 
-def test_PP004_setup_cfg_no_cap(tmp_path: Path):
-    (tmp_path / "setup.cfg").write_text(
-        inspect.cleandoc("""
-            [options]
-            python_requires = >=3.10
-            """),
-        encoding="utf-8",
-    )
+def test_PP004_setup_cfg_no_cap():
+    contents = inspect.cleandoc("""
+        [options]
+        python_requires = >=3.10
+        """)
+    config = configparser.ConfigParser()
+    config.read_string(contents)
+
+    assert compute_check("PP004", pyproject={}, setupcfg=config).result
+
+
+def test_PP004_setup_cfg_cap():
+    contents = inspect.cleandoc("""
+        [options]
+        python_requires = >=3.10,<4
+        """)
+    config = configparser.ConfigParser()
+    config.read_string(contents)
 
-    assert compute_check("PP004", pyproject={}, package=tmp_path).result
+    assert compute_check("PP004", pyproject={}, setupcfg=config).result is False
 
 
-def test_PP004_setup_cfg_cap(tmp_path: Path):
-    (tmp_path / "setup.cfg").write_text(
-        inspect.cleandoc("""
-            [options]
-            python_requires = >=3.10,<4
-            """),
-        encoding="utf-8",
-    )
+def test_PP004_setup_cfg_no_section():
+    contents = inspect.cleandoc("""
+        [other]
+        python_requires = >=3.10
+        """)
+    config = configparser.ConfigParser()
+    config.read_string(contents)
+
+    assert not compute_check("PP004", pyproject={}, setupcfg=config).result
+
+
+def test_PP004_setup_cfg_no_value():
+    contents = inspect.cleandoc("""
+        [options]
+        other = >=3.10
+        """)
+    config = configparser.ConfigParser()
+    config.read_string(contents)
 
-    assert compute_check("PP004", pyproject={}, package=tmp_path).result is False
+    assert not compute_check("PP004", pyproject={}, setupcfg=config).result
 
 
-def test_PP004_not_present(tmp_path: Path):
-    assert compute_check("PP004", pyproject={}, package=tmp_path).result is None
+def test_PP004_not_present():
+    assert compute_check("PP004", pyproject={}, setupcfg=None).result is None
 
 
 def test_PP005_no_license():