From c7a1ca2154a17d8350186bb5ac384c613471e4ee Mon Sep 17 00:00:00 2001
From: Adam Turner <9087854+aa-turner@users.noreply.github.com>
Date: Sun, 28 Jul 2024 05:02:26 +0100
Subject: [PATCH] Adopt Ruff and use stricter MyPy settings

---
 .flake8                                   |  4 --
 .github/workflows/test.yml                |  4 +-
 .ruff.toml                                | 53 +++++++++++++++++++++++
 Makefile                                  |  2 +-
 pyproject.toml                            | 36 +++++++++++++--
 sphinxcontrib/serializinghtml/__init__.py | 24 +++++++---
 sphinxcontrib/serializinghtml/jsonimpl.py |  6 +--
 sphinxcontrib/serializinghtml/py.typed    |  0
 tests/conftest.py                         | 15 +++----
 tests/test_serializinghtml.py             | 11 ++++-
 tox.ini                                   |  6 +--
 11 files changed, 128 insertions(+), 33 deletions(-)
 delete mode 100644 .flake8
 create mode 100644 .ruff.toml
 create mode 100644 sphinxcontrib/serializinghtml/py.typed

diff --git a/.flake8 b/.flake8
deleted file mode 100644
index 5af0a95..0000000
--- a/.flake8
+++ /dev/null
@@ -1,4 +0,0 @@
-[flake8]
-max-line-length = 95
-ignore = E116,E241,E251
-exclude = .git,.tox,.venv
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8cd7db1..856df5b 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -71,7 +71,9 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        env: [flake8, mypy]
+        env:
+          - ruff
+          - mypy
 
     steps:
       - uses: actions/checkout@v3
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..4b7dd2a
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,53 @@
+target-version = "py39"  # Pin Ruff to Python 3.9
+output-format = "full"
+line-length = 95
+
+[lint]
+preview = true
+select = [
+#    "ANN",   # flake8-annotations
+    "C4",    # flake8-comprehensions
+    "COM",   # flake8-commas
+    "B",     # flake8-bugbear
+    "DTZ",   # flake8-datetimez
+    "E",     # pycodestyle
+    "EM",    # flake8-errmsg
+    "EXE",   # flake8-executable
+    "F",     # pyflakes
+    "FA",    # flake8-future-annotations
+    "FLY",   # flynt
+    "FURB",  # refurb
+    "G",     # flake8-logging-format
+    "I",     # isort
+    "ICN",   # flake8-import-conventions
+    "INT",   # flake8-gettext
+    "LOG",   # flake8-logging
+    "PERF",  # perflint
+    "PGH",   # pygrep-hooks
+    "PIE",   # flake8-pie
+    "PT",    # flake8-pytest-style
+    "SIM",   # flake8-simplify
+    "SLOT",  # flake8-slots
+    "TCH",   # flake8-type-checking
+    "UP",    # pyupgrade
+    "W",     # pycodestyle
+    "YTT",   # flake8-2020
+]
+ignore = [
+    "E116",
+    "E241",
+    "E251",
+]
+
+[lint.per-file-ignores]
+"tests/*" = [
+    "ANN",  # tests don't need annotations
+]
+
+[lint.isort]
+forced-separate = [
+    "tests",
+]
+required-imports = [
+    "from __future__ import annotations",
+]
diff --git a/Makefile b/Makefile
index 26f411a..438ee54 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ clean-mypyfiles:
 
 .PHONY: style-check
 style-check:
-	@flake8
+	@ruff check
 
 .PHONY: type-check
 type-check:
diff --git a/pyproject.toml b/pyproject.toml
index 7f88862..27d3008 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -48,9 +48,9 @@ test = [
     "pytest",
 ]
 lint = [
-    "flake8",
+    "ruff==0.5.5",
     "mypy",
-    "docutils-stubs",
+    "types-docutils",
 ]
 standalone = [
     "Sphinx>=5",
@@ -76,4 +76,34 @@ exclude = [
 ]
 
 [tool.mypy]
-ignore_missing_imports = true
+python_version = "3.9"
+packages = [
+    "sphinxcontrib",
+    "tests",
+]
+exclude = [
+    "tests/roots",
+]
+check_untyped_defs = true
+disallow_any_generics = true
+disallow_incomplete_defs = true
+disallow_subclassing_any = true
+disallow_untyped_calls = true
+disallow_untyped_decorators = true
+disallow_untyped_defs = true
+explicit_package_bases = true
+extra_checks = true
+no_implicit_reexport = true
+show_column_numbers = true
+show_error_context = true
+strict_optional = true
+warn_redundant_casts = true
+warn_unused_configs = true
+warn_unused_ignores = true
+enable_error_code = [
+    "type-arg",
+    "redundant-self",
+    "truthy-iterable",
+    "ignore-without-code",
+    "unused-awaitable",
+]
diff --git a/sphinxcontrib/serializinghtml/__init__.py b/sphinxcontrib/serializinghtml/__init__.py
index 11301c4..ee41019 100644
--- a/sphinxcontrib/serializinghtml/__init__.py
+++ b/sphinxcontrib/serializinghtml/__init__.py
@@ -4,7 +4,7 @@
 import pickle
 import types
 from os import path
-from typing import Any
+from typing import TYPE_CHECKING
 
 from sphinx.application import ENV_PICKLE_FILENAME, Sphinx
 from sphinx.builders.html import BuildInfo, StandaloneHTMLBuilder
@@ -13,6 +13,16 @@
 
 from sphinxcontrib.serializinghtml import jsonimpl
 
+if TYPE_CHECKING:
+    from collections.abc import Sequence
+    from typing import Any, Protocol
+
+    class SerialisingImplementation(Protocol):
+        def dump(self, obj: Any, file: Any, *args: Any, **kwargs: Any) -> None: ...
+        def dumps(self, obj: Any, *args: Any, **kwargs: Any) -> str | bytes: ...
+        def load(self, file: Any, *args: Any, **kwargs: Any) -> Any: ...
+        def loads(self, data: Any, *args: Any, **kwargs: Any) -> Any: ...
+
 __version__ = '1.1.10'
 __version_info__ = (1, 1, 10)
 
@@ -31,11 +41,11 @@ class SerializingHTMLBuilder(StandaloneHTMLBuilder):
     """
     #: the serializing implementation to use.  Set this to a module that
     #: implements a `dump`, `load`, `dumps` and `loads` functions
-    #: (pickle, simplejson etc.)
-    implementation: Any = None
+    #: (pickle, json etc.)
+    implementation: SerialisingImplementation
     implementation_dumps_unicode = False
     #: additional arguments for dump()
-    additional_dump_args: tuple = ()
+    additional_dump_args: Sequence[Any] = ()
 
     #: the filename for the global context file
     globalcontext_filename: str = ''
@@ -62,7 +72,7 @@ def get_target_uri(self, docname: str, typ: str | None = None) -> str:
             return docname[:-5]  # up to sep
         return docname + SEP
 
-    def dump_context(self, context: dict, filename: str | os.PathLike[str]) -> None:
+    def dump_context(self, context: dict[str, Any], filename: str | os.PathLike[str]) -> None:
         context = context.copy()
         if 'css_files' in context:
             context['css_files'] = [css.filename for css in context['css_files']]
@@ -75,7 +85,7 @@ def dump_context(self, context: dict, filename: str | os.PathLike[str]) -> None:
             with open(filename, 'wb') as fb:
                 self.implementation.dump(context, fb, *self.additional_dump_args)
 
-    def handle_page(self, pagename: str, ctx: dict, templatename: str = 'page.html',
+    def handle_page(self, pagename: str, ctx: dict[str, Any], templatename: str = 'page.html',
                     outfilename: str | None = None, event_arg: Any = None) -> None:
         ctx['current_page_name'] = pagename
         ctx.setdefault('pathto', lambda p: p)
@@ -132,7 +142,7 @@ class PickleHTMLBuilder(SerializingHTMLBuilder):
 
     implementation = pickle
     implementation_dumps_unicode = False
-    additional_dump_args = (pickle.HIGHEST_PROTOCOL,)
+    additional_dump_args: tuple[Any] = (pickle.HIGHEST_PROTOCOL,)
     indexer_format = pickle
     indexer_dumps_unicode = False
     out_suffix = '.fpickle'
diff --git a/sphinxcontrib/serializinghtml/jsonimpl.py b/sphinxcontrib/serializinghtml/jsonimpl.py
index e80c047..9b89875 100644
--- a/sphinxcontrib/serializinghtml/jsonimpl.py
+++ b/sphinxcontrib/serializinghtml/jsonimpl.py
@@ -4,7 +4,7 @@
 
 import json
 from collections import UserString
-from typing import Any, IO
+from typing import IO, Any
 
 
 class SphinxJSONEncoder(json.JSONEncoder):
@@ -15,9 +15,9 @@ def default(self, obj: Any) -> str:
         return super().default(obj)
 
 
-def dump(obj: Any, fp: IO, *args: Any, **kwds: Any) -> None:
+def dump(obj: Any, file: IO[str] | IO[bytes], *args: Any, **kwds: Any) -> None:
     kwds['cls'] = SphinxJSONEncoder
-    json.dump(obj, fp, *args, **kwds)
+    json.dump(obj, file, *args, **kwds)
 
 
 def dumps(obj: Any, *args: Any, **kwds: Any) -> str:
diff --git a/sphinxcontrib/serializinghtml/py.typed b/sphinxcontrib/serializinghtml/py.typed
new file mode 100644
index 0000000..e69de29
diff --git a/tests/conftest.py b/tests/conftest.py
index d4b08e5..3934d3f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,17 +1,14 @@
+from __future__ import annotations
+
 from pathlib import Path
 
 import pytest
 
-import sphinx
-
-pytest_plugins = 'sphinx.testing.fixtures'
+pytest_plugins = (
+    'sphinx.testing.fixtures',
+)
 
 
 @pytest.fixture(scope='session')
-def rootdir():
-    if sphinx.version_info[:2] < (7, 2):
-        from sphinx.testing.path import path
-
-        return path(__file__).parent.abspath() / 'roots'
-
+def rootdir() -> Path:
     return Path(__file__).resolve().parent / 'roots'
diff --git a/tests/test_serializinghtml.py b/tests/test_serializinghtml.py
index b6f6380..480930b 100644
--- a/tests/test_serializinghtml.py
+++ b/tests/test_serializinghtml.py
@@ -1,13 +1,20 @@
 """Test for serializinghtml extension."""
 
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
 import pytest
 
+if TYPE_CHECKING:
+    from sphinx.application import Sphinx
+
 
 @pytest.mark.sphinx('json', testroot='basic')
-def test_json(app, status, warning):
+def test_json(app: Sphinx) -> None:
     app.builder.build_all()
 
 
 @pytest.mark.sphinx('pickle', testroot='basic')
-def test_pickle(app, status, warning):
+def test_pickle(app: Sphinx) -> None:
     app.builder.build_all()
diff --git a/tox.ini b/tox.ini
index 9ad9f73..233022d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,7 +2,7 @@
 minversion = 2.4.0
 envlist =
     py{39,310,311,312,313},
-    flake8,
+    ruff,
     mypy
 isolated_build = True
 
@@ -18,14 +18,14 @@ setenv =
 commands=
     pytest --durations 25 {posargs}
 
-[testenv:flake8]
+[testenv:ruff]
 description =
     Run style checks.
 extras =
     test
     lint
 commands=
-    flake8
+    ruff check
 
 [testenv:mypy]
 description =