[PATCH 04/14] Add ruff format check to CI

Tamir Duberstein <[email protected]>
Newsgroups org.kernel.linux.tools
Message-ID <[email protected]>
Enable ruff format checking in the b4 CI script and configure Ruff's
formatter in pyproject.toml.

Apply a one-time repo-wide format pass so the new check enforces the
current style without leaving the branch permanently red.

Signed-off-by: Tamir Duberstein <[email protected]>
---
 pyproject.toml             |   3 +
 src/liblore/__init__.py    |   1 +
 src/liblore/node.py        |  61 ++++++-----
 src/liblore/utils.py       |  86 +++++++++++-----
 tests/conftest.py          |   1 +
 tests/test_auth_headers.py |   4 +
 tests/test_cache.py        |  23 +++--
 tests/test_email_utils.py  |   2 +-
 tests/test_formatting.py   | 251 ++++++++++++++++++++++++++++-----------------
 tests/test_mbox.py         |   3 +
 tests/test_message.py      |   1 +
 tests/test_node.py         | 152 +++++++++++++++++++--------
 tests/test_thread.py       |   1 +
 tools/b4-ci-check.py       |   6 ++
 14 files changed, 400 insertions(+), 195 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index 38be519..9bfadbe 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -59,3 +59,6 @@ ignore_missing_imports = true
 
 [tool.ruff.lint]
 extend-select = ["I"]
+
+[tool.ruff.format]
+quote-style = "single"
diff --git a/src/liblore/__init__.py b/src/liblore/__init__.py
index 1ffaaeb..2ba94e7 100644
--- a/src/liblore/__init__.py
+++ b/src/liblore/__init__.py
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 # Copyright (C) 2025-2026 The Linux Foundation
 """liblore — shared library for public-inbox / lore.kernel.org access."""
+
 import email.charset
 import email.policy
 from email.message import EmailMessage
diff --git a/src/liblore/node.py b/src/liblore/node.py
index 66da1d7..503428b 100644
--- a/src/liblore/node.py
+++ b/src/liblore/node.py
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 # Copyright (C) 2025-2026 The Linux Foundation
 """LoreNode — primary API for interacting with public-inbox servers."""
+
 from __future__ import annotations
 
 import concurrent.futures
@@ -49,7 +50,9 @@ def _get_config_from_git(
     try:
         result = subprocess.run(
             ['git', 'config', '-z', '--get-regexp', regexp],
-            capture_output=True, text=True, timeout=5,
+            capture_output=True,
+            text=True,
+            timeout=5,
         )
         if result.returncode != 0:
             return {}
@@ -99,7 +102,9 @@ def _get_subsection_config(
     try:
         result = subprocess.run(
             ['git', 'config', '-z', '--get-regexp', f'^{escaped}'],
-            capture_output=True, text=True, timeout=5,
+            capture_output=True,
+            text=True,
+            timeout=5,
         )
         if result.returncode != 0:
             return {}
@@ -114,7 +119,7 @@ def _get_subsection_config(
             key, value = entry.split('\n', 1)
         else:
             key, value = entry, 'true'
-        varname = key[len(prefix):].lower()
+        varname = key[len(prefix) :].lower()
         if varname in multivals:
             existing = config.get(varname)
             if not isinstance(existing, list):
@@ -168,7 +173,7 @@ class LoreNode:
 
         # Build the ordered list of origins to try
         self._all_origins: list[str] = []
-        for fb in (fallback_urls or []):
+        for fb in fallback_urls or []:
             fb = fb.rstrip('/')
             fb_parsed = urllib.parse.urlparse(fb)
             if not fb_parsed.scheme or not fb_parsed.netloc:
@@ -191,6 +196,7 @@ class LoreNode:
         if add_auth_headers:
             try:
                 import authheaders
+
                 self._authheaders = authheaders
             except ImportError:
                 raise LibloreError(
@@ -280,7 +286,9 @@ class LoreNode:
         # Try [liblore "<origin>"] first, fall back to [lore] for
         # lore.kernel.org.
         gitcfg = _get_subsection_config(
-            'liblore', origin, multivals=['fallback'],
+            'liblore',
+            origin,
+            multivals=['fallback'],
         )
         if not gitcfg and parsed.netloc == 'lore.kernel.org':
             gitcfg = _get_config_from_git(r'^lore\.', multivals=['fallback'])
@@ -334,7 +342,9 @@ class LoreNode:
         """
         return self._user_agent_plus
 
-    def set_user_agent(self, app_name: str, version: str, plus: str | None = None) -> None:
+    def set_user_agent(
+        self, app_name: str, version: str, plus: str | None = None
+    ) -> None:
         """Set the User-Agent to ``app_name/version`` (optionally ``+plus``).
 
         When *plus* is not provided, the value from ``lore.useragentplus``
@@ -415,7 +425,7 @@ class LoreNode:
 
     def _rewrite_url(self, url: str, origin: str) -> str:
         """Replace the canonical origin in *url* with *origin*."""
-        return origin + url[len(self._canonical_origin):]
+        return origin + url[len(self._canonical_origin) :]
 
     def _probe_cache_key(self) -> str:
         """Cache key for probe results, stable regardless of current order."""
@@ -596,12 +606,14 @@ class LoreNode:
             logger.debug('Trying %s %s', method, request_url)
             try:
                 resp: requests.Response = getattr(
-                    session, method.lower(),
+                    session,
+                    method.lower(),
                 )(request_url, **kwargs)
             except (requests.ConnectionError, requests.Timeout) as exc:
                 logger.warning(
                     'Request to %s failed (%s), trying next host',
-                    origin, exc,
+                    origin,
+                    exc,
                 )
                 last_exc = exc
                 continue
@@ -609,7 +621,8 @@ class LoreNode:
             if resp.status_code >= 500:
                 logger.warning(
                     'Request to %s returned %d, trying next host',
-                    origin, resp.status_code,
+                    origin,
+                    resp.status_code,
                 )
                 last_resp = resp
                 continue
@@ -622,9 +635,7 @@ class LoreNode:
             return last_resp
 
         if last_exc is not None:
-            raise RemoteError(
-                f'All hosts failed for {url}: {last_exc}'
-            ) from last_exc
+            raise RemoteError(f'All hosts failed for {url}: {last_exc}') from last_exc
 
         # last_resp must be a 5xx from the final origin
         assert last_resp is not None
@@ -650,7 +661,9 @@ class LoreNode:
             return None
         age = int(time.time() - st.st_mtime)
         if age > self._cache_ttl:
-            logger.debug('Cache expired (%ds > %ds): %s', age, self._cache_ttl, key[:12])
+            logger.debug(
+                'Cache expired (%ds > %ds): %s', age, self._cache_ttl, key[:12]
+            )
             try:
                 os.unlink(path)
             except OSError:
@@ -699,8 +712,12 @@ class LoreNode:
         for msg in msgs:
             msg_bytes = msg.as_bytes()
             auth_result = self._authheaders.authenticate_message(
-                msg_bytes, 'liblore',
-                dkim=True, dmarc=True, arc=True, spf=False,
+                msg_bytes,
+                'liblore',
+                dkim=True,
+                dmarc=True,
+                arc=True,
+                spf=False,
             )
             if auth_result:
                 # authheaders returns the full header line, so strip
@@ -785,7 +802,9 @@ class LoreNode:
                 return cached
 
         t_param = '&t=1' if full_threads else ''
-        query_url = self._url + '/?x=m' + t_param + '&q=' + urllib.parse.quote_plus(query)
+        query_url = (
+            self._url + '/?x=m' + t_param + '&q=' + urllib.parse.quote_plus(query)
+        )
         resp = self._request('POST', query_url, data='x=m')
         if resp.status_code != 200:
             raise RemoteError('Server returned an error: %s' % resp.status_code)
@@ -842,9 +861,7 @@ class LoreNode:
         if strict:
             strict_msgs = get_strict_thread(msgs, msgid)
             if not isinstance(strict_msgs, list) or not len(strict_msgs):
-                raise LookupError(
-                    'No messages found for msgid=%s' % msgid
-                )
+                raise LookupError('No messages found for msgid=%s' % msgid)
             msgs = strict_msgs
 
         if sort:
@@ -1013,9 +1030,7 @@ class LoreNode:
         for i, query in enumerate(queries):
             if i > 0:
                 time.sleep(0.1)
-            results.append(
-                self.get_thread_by_query(query, full_threads=full_threads)
-            )
+            results.append(self.get_thread_by_query(query, full_threads=full_threads))
         return results
 
     def validate(self) -> None:
diff --git a/src/liblore/utils.py b/src/liblore/utils.py
index e8926c6..04823bf 100644
--- a/src/liblore/utils.py
+++ b/src/liblore/utils.py
@@ -1,6 +1,7 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 # Copyright (C) 2025-2026 The Linux Foundation
 """Message parsing, email utilities, threading, and mbox splitting."""
+
 from __future__ import annotations
 
 import datetime
@@ -36,6 +37,7 @@ _GTFROM_RE = re.compile(rb'^>+From ')
 # Header and message-ID utilities
 # =====================================================================
 
+
 def clean_header(hdrval: str | None) -> str:
     """Decode an RFC 2047 encoded header and normalise whitespace.
 
@@ -166,7 +168,6 @@ def msg_get_payload(
     return '\n'.join(stripped)
 
 
-
 # Adapted from email._parseaddr — RFC 5322 specials that require quoting
 _QSPECIALS = re.compile(r'[()<>@,:;.\"\[\]]')
 
@@ -187,7 +188,11 @@ def format_addrs(pairs: list[tuple[str, str]], clean: bool = True) -> str:
         if clean:
             name = clean_header(name)
         # Work around https://github.com/python/cpython/issues/100900
-        if not name.startswith('=?') and not name.startswith('"') and _QSPECIALS.search(name):
+        if (
+            not name.startswith('=?')
+            and not name.startswith('"')
+            and _QSPECIALS.search(name)
+        ):
             addrs.append(f'"{email.utils.quote(name)}" <{addr}>')
             continue
         if name.isascii():
@@ -216,7 +221,8 @@ def wrap_header(hdr: tuple[str, str], width: int = 75, nl: str = '\n') -> bytes:
         for addr in email.utils.getaddresses([hval]):
             if not addr[0].isascii():
                 enc_name = email.quoprimime.header_encode(
-                    addr[0].encode(), charset='utf-8')
+                    addr[0].encode(), charset='utf-8'
+                )
                 qp = format_addrs([(enc_name, addr[1])], clean=False)
             else:
                 qp = format_addrs([addr], clean=False)
@@ -236,14 +242,19 @@ def wrap_header(hdr: tuple[str, str], width: int = 75, nl: str = '\n') -> bytes:
                 return hdata.encode()
             # Trick: replace ': ' with ':_' so textwrap doesn't break there
             hdata = hdata.replace(': ', ':_', 1)
-            wrapped = textwrap.wrap(hdata, break_long_words=False,
-                                    break_on_hyphens=False,
-                                    subsequent_indent=' ', width=width)
+            wrapped = textwrap.wrap(
+                hdata,
+                break_long_words=False,
+                break_on_hyphens=False,
+                subsequent_indent=' ',
+                width=width,
+            )
             return nl.join(wrapped).replace(':_', ': ', 1).encode()
 
         # Non-ASCII: encode as RFC 2047 quoted-printable
         qp = f'{hname}: ' + email.quoprimime.header_encode(
-            hval.encode(), charset='utf-8')
+            hval.encode(), charset='utf-8'
+        )
         if len(qp) <= width:
             return qp.encode()
 
@@ -252,7 +263,7 @@ def wrap_header(hdr: tuple[str, str], width: int = 75, nl: str = '\n') -> bytes:
             if len(_parts):
                 wrapat -= 1
             # Don't break in the middle of a =XX escape sequence
-            while '=' in qp[wrapat - 2:wrapat]:
+            while '=' in qp[wrapat - 2 : wrapat]:
                 wrapat -= 1
             _parts.append(qp[:wrapat] + '?=')
             qp = '=?utf-8?q?' + qp[wrapat:]
@@ -434,8 +445,14 @@ DIFFSTAT_RE = re.compile(r'^\s*\d+ file.*changed', re.M)
 
 # Default set of headers to keep when minimizing messages.
 MINIMIZE_KEEP_HEADERS: tuple[str, ...] = (
-    'From', 'To', 'Cc', 'Subject', 'Date',
-    'Message-ID', 'Reply-To', 'In-Reply-To',
+    'From',
+    'To',
+    'Cc',
+    'Subject',
+    'Date',
+    'Message-ID',
+    'Reply-To',
+    'In-Reply-To',
 )
 
 
@@ -481,7 +498,11 @@ def minimize_thread(
 
         body = msg_get_payload(msg, strip_signature=False)
 
-        if not re.search(r'^>', body, re.M) or DIFF_RE.search(body) or DIFFSTAT_RE.search(body):
+        if (
+            not re.search(r'^>', body, re.M)
+            or DIFF_RE.search(body)
+            or DIFFSTAT_RE.search(body)
+        ):
             mmsg.set_payload(body, charset='utf-8')
             mmsgs.append(mmsg)
             continue
@@ -548,11 +569,15 @@ def minimize_thread(
                 skipped = last_break
                 if skipped <= 5:
                     continue
-                last_para = qchunk[last_break + 1:]
-                chunks[idx] = (True, [
-                    f'> [... skip {skipped} lines ...]',
-                    '>',
-                ] + last_para)
+                last_para = qchunk[last_break + 1 :]
+                chunks[idx] = (
+                    True,
+                    [
+                        f'> [... skip {skipped} lines ...]',
+                        '>',
+                    ]
+                    + last_para,
+                )
 
         parts: list[str] = []
         for _quoted, chunk in chunks:
@@ -599,7 +624,7 @@ def _get_raw_header(raw: bytes, name: str) -> str | None:
         if value is not None:
             break
         if line.lower().startswith(prefix):
-            value = line[len(prefix):].strip()
+            value = line[len(prefix) :].strip()
 
     if value is not None:
         return value.decode('ascii', errors='replace')
@@ -680,7 +705,8 @@ DEFAULT_LISTID_PREFERENCE: list[str] = [
 
 
 def _listid_preference_index(
-    listid: str | None, listid_preference: list[str],
+    listid: str | None,
+    listid_preference: list[str],
 ) -> int:
     """Return the preference index for a List-Id value.
 
@@ -716,16 +742,17 @@ def get_preferred_duplicate(
     if listid_preference is None:
         listid_preference = DEFAULT_LISTID_PREFERENCE
 
-    idx1 = _listid_preference_index(
-        get_clean_msgid(msg1, 'List-Id'), listid_preference)
-    idx2 = _listid_preference_index(
-        get_clean_msgid(msg2, 'List-Id'), listid_preference)
+    idx1 = _listid_preference_index(get_clean_msgid(msg1, 'List-Id'), listid_preference)
+    idx2 = _listid_preference_index(get_clean_msgid(msg2, 'List-Id'), listid_preference)
     if idx1 <= idx2:
-        logger.debug('Picked duplicate from preferred source: %s',
-                      get_clean_msgid(msg1, 'List-Id'))
+        logger.debug(
+            'Picked duplicate from preferred source: %s',
+            get_clean_msgid(msg1, 'List-Id'),
+        )
         return msg1
-    logger.debug('Picked duplicate from preferred source: %s',
-                  get_clean_msgid(msg2, 'List-Id'))
+    logger.debug(
+        'Picked duplicate from preferred source: %s', get_clean_msgid(msg2, 'List-Id')
+    )
     return msg2
 
 
@@ -799,9 +826,10 @@ def split_and_dedupe(
 
 
 # =====================================================================
-# Pure helpers 
+# Pure helpers
 # =====================================================================
 
+
 def get_msgid_from_url(url: str) -> str:
     """Extract a message ID from a lore.kernel.org URL.
 
@@ -809,7 +837,9 @@ def get_msgid_from_url(url: str) -> str:
     returned with angle brackets stripped.
     """
     if '://' in url:
-        matches = re.search(r'^https?://[^@]+/([^/]+(?:@|%40)[^/]+)', url, re.IGNORECASE)
+        matches = re.search(
+            r'^https?://[^@]+/([^/]+(?:@|%40)[^/]+)', url, re.IGNORECASE
+        )
         if matches:
             return urllib.parse.unquote(matches.groups()[0])
     return url.strip('<>')
diff --git a/tests/conftest.py b/tests/conftest.py
index ec8ec80..28fedaf 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Shared fixtures for liblore tests."""
+
 from __future__ import annotations
 
 import email.utils
diff --git a/tests/test_auth_headers.py b/tests/test_auth_headers.py
index 9097048..8cec624 100644
--- a/tests/test_auth_headers.py
+++ b/tests/test_auth_headers.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for optional authheaders integration in LoreNode."""
+
 from __future__ import annotations
 
 import gzip
@@ -17,6 +18,7 @@ from liblore.node import LoreNode
 # Import-time validation
 # =====================================================================
 
+
 class TestAuthHeadersImport:
     def test_raises_when_authheaders_missing(self) -> None:
         with patch.dict(sys.modules, {'authheaders': None}):
@@ -41,6 +43,7 @@ class TestAuthHeadersImport:
 # _authenticate_msgs
 # =====================================================================
 
+
 class TestAuthenticateMsgs:
     def test_noop_when_disabled(self) -> None:
         node = LoreNode()
@@ -116,6 +119,7 @@ class TestAuthenticateMsgs:
 # Integration with fetch methods
 # =====================================================================
 
+
 class TestAuthInFetchMethods:
     @pytest.fixture()
     def auth_node(self, sample_mbox: bytes) -> LoreNode:
diff --git a/tests/test_cache.py b/tests/test_cache.py
index 9e7933b..b822e7c 100644
--- a/tests/test_cache.py
+++ b/tests/test_cache.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for LoreNode filesystem caching."""
+
 from __future__ import annotations
 
 import gzip
@@ -82,7 +83,6 @@ class TestCacheReadWrite:
 
 
 class TestClearCache:
-
     def test_clears_cache_files(self, tmp_path: Path) -> None:
         node = LoreNode(cache_dir=str(tmp_path))
         node._cache_write(node._cache_key('a', '1'), b'data1')
@@ -104,7 +104,6 @@ class TestClearCache:
 
 
 class TestProperties:
-
     def test_url_property(self) -> None:
         node = LoreNode('https://lore.kernel.org/all/')
         # Trailing slash is stripped by constructor
@@ -122,8 +121,12 @@ class TestProperties:
 class TestCachedMethods:
     """Integration tests: verify cache hit avoids network, cache miss hits network."""
 
-    def _make_node(self, tmp_path: Path, sample_mbox: bytes) -> tuple[LoreNode, MagicMock]:
-        node = LoreNode('https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60)
+    def _make_node(
+        self, tmp_path: Path, sample_mbox: bytes
+    ) -> tuple[LoreNode, MagicMock]:
+        node = LoreNode(
+            'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60
+        )
         mock_session = MagicMock()
         mock_resp = MagicMock()
         mock_resp.status_code = 200
@@ -158,7 +161,9 @@ class TestCachedMethods:
         assert mock_session.post.call_count == 2
 
     def test_get_message_by_msgid_caches(self, tmp_path: Path) -> None:
-        node = LoreNode('https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60)
+        node = LoreNode(
+            'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60
+        )
         mock_session = MagicMock()
         mock_resp = MagicMock()
         mock_resp.status_code = 200
@@ -197,7 +202,9 @@ class TestCachedMethods:
         # tmp_path should be empty since we didn't set cache_dir to it
         assert list(tmp_path.iterdir()) == []
 
-    def test_fetch_thread_since_not_cached(self, tmp_path: Path, sample_mbox: bytes) -> None:
+    def test_fetch_thread_since_not_cached(
+        self, tmp_path: Path, sample_mbox: bytes
+    ) -> None:
         """_fetch_thread_since should NOT be cached."""
         node, mock_session = self._make_node(tmp_path, sample_mbox)
         node._fetch_thread_since('[email protected]', 'dt:20240101..')
@@ -207,7 +214,9 @@ class TestCachedMethods:
 
     def test_error_not_cached(self, tmp_path: Path) -> None:
         """Network errors should not be cached."""
-        node = LoreNode('https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60)
+        node = LoreNode(
+            'https://lore.kernel.org/all', cache_dir=str(tmp_path), cache_ttl=60
+        )
         mock_session = MagicMock()
         mock_resp = MagicMock()
         mock_resp.status_code = 404
diff --git a/tests/test_email_utils.py b/tests/test_email_utils.py
index 74216d4..9276010 100644
--- a/tests/test_email_utils.py
+++ b/tests/test_email_utils.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for liblore.email_utils."""
+
 from __future__ import annotations
 
 from email.message import EmailMessage
@@ -100,7 +101,6 @@ class TestMsgGetPayload:
         assert msg_get_payload(msg) == ''
 
 
-
 class TestMsgGetRecipients:
     def test_to_cc_from(self) -> None:
         msg = EmailMessage(policy=emlpolicy)
diff --git a/tests/test_formatting.py b/tests/test_formatting.py
index cb3cf80..f179a3a 100644
--- a/tests/test_formatting.py
+++ b/tests/test_formatting.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for email formatting and thread minimization."""
+
 from __future__ import annotations
 
 from email.message import EmailMessage
@@ -19,105 +20,168 @@ from liblore.utils import (
 # Adapted from b4 test_format_addrs — validates cpython#100900 workaround
 # and RFC 2047 handling in address formatting.
 class TestFormatAddrs:
-    @pytest.mark.parametrize('pairs,verify,clean', [
-        ([('', '[email protected]'), ('Foo Bar', '[email protected]')],
-         '[email protected], Foo Bar <[email protected]>', True),
-        ([('', '[email protected]'), ('Foo, Bar', '[email protected]')],
-         '[email protected], "Foo, Bar" <[email protected]>', True),
-        ([('', '[email protected]'), ('F\u00f4o, Bar', '[email protected]')],
-         '[email protected], "F\u00f4o, Bar" <[email protected]>', True),
-        ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', '[email protected]')],
-         '[email protected], Qu\u00fbx Foo <[email protected]>', True),
-        ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]')],
-         '[email protected], "Qu\u00fbx, Foo" <[email protected]>', True),
-        ([('', '[email protected]'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]')],
-         '[email protected], =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>', False),
-    ])
-    def test_format_addrs(self, pairs: list[tuple[str, str]],
-                          verify: str, clean: bool) -> None:
+    @pytest.mark.parametrize(
+        'pairs,verify,clean',
+        [
+            (
+                [('', '[email protected]'), ('Foo Bar', '[email protected]')],
+                '[email protected], Foo Bar <[email protected]>',
+                True,
+            ),
+            (
+                [('', '[email protected]'), ('Foo, Bar', '[email protected]')],
+                '[email protected], "Foo, Bar" <[email protected]>',
+                True,
+            ),
+            (
+                [('', '[email protected]'), ('F\u00f4o, Bar', '[email protected]')],
+                '[email protected], "F\u00f4o, Bar" <[email protected]>',
+                True,
+            ),
+            (
+                [
+                    ('', '[email protected]'),
+                    ('=?utf-8?q?Qu=C3=BBx_Foo?=', '[email protected]'),
+                ],
+                '[email protected], Qu\u00fbx Foo <[email protected]>',
+                True,
+            ),
+            (
+                [
+                    ('', '[email protected]'),
+                    ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]'),
+                ],
+                '[email protected], "Qu\u00fbx, Foo" <[email protected]>',
+                True,
+            ),
+            (
+                [
+                    ('', '[email protected]'),
+                    ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', '[email protected]'),
+                ],
+                '[email protected], =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>',
+                False,
+            ),
+        ],
+    )
+    def test_format_addrs(
+        self, pairs: list[tuple[str, str]], verify: str, clean: bool
+    ) -> None:
         assert format_addrs(pairs, clean) == verify
 
 
 # Adapted from b4 test_header_wrapping — validates RFC 2047 encoding,
 # line wrapping, and proper handling of address vs non-address headers.
 class TestWrapHeader:
-    @pytest.mark.parametrize('hval,verify', [
-        # Short ASCII — no wrapping needed
-        ('short-ascii', 'short-ascii'),
-        # Short Unicode — RFC 2047 QP encoded
-        ('short-unic\u00f4de', '=?utf-8?q?short-unic=C3=B4de?='),
-        # Long ASCII — wrapped at word boundary
-        ('Lorem ipsum dolor sit amet consectetur adipiscing elit '
-         'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua',
-         'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n'
-         ' eiusmod tempor incididunt ut labore et dolore magna aliqua'),
-        # Long Unicode — split across multiple encoded lines
-        ('Lorem \u00eepsum dolor sit amet consectetur adipiscing el\u00eet '
-         'sed do eiusmod temp\u00f4r incididunt ut labore et dol\u00f4re magna aliqua',
-         '=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n'
-         ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n'
-         ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?='),
-        # Exactly 75 chars — boundary condition
-        ('Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
-         'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu'),
-        # Unicode on escape boundary
-        ('Lorem ipsum dolor sit amet consectetur adipiscin el\u00eet',
-         '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n'
-         ' =?utf-8?q?=C3=AEt?='),
-        # Unicode 1 char too long
-        ('Lorem ipsum dolor sit amet consectetur adipi el\u00eet',
-         '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n'
-         ' =?utf-8?q?t?='),
-    ])
+    @pytest.mark.parametrize(
+        'hval,verify',
+        [
+            # Short ASCII — no wrapping needed
+            ('short-ascii', 'short-ascii'),
+            # Short Unicode — RFC 2047 QP encoded
+            ('short-unic\u00f4de', '=?utf-8?q?short-unic=C3=B4de?='),
+            # Long ASCII — wrapped at word boundary
+            (
+                'Lorem ipsum dolor sit amet consectetur adipiscing elit '
+                'sed do eiusmod tempor incididunt ut labore et dolore magna aliqua',
+                'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do\n'
+                ' eiusmod tempor incididunt ut labore et dolore magna aliqua',
+            ),
+            # Long Unicode — split across multiple encoded lines
+            (
+                'Lorem \u00eepsum dolor sit amet consectetur adipiscing el\u00eet '
+                'sed do eiusmod temp\u00f4r incididunt ut labore et dol\u00f4re magna aliqua',
+                '=?utf-8?q?Lorem_=C3=AEpsum_dolor_sit_amet_consectetur_adipiscin?=\n'
+                ' =?utf-8?q?g_el=C3=AEt_sed_do_eiusmod_temp=C3=B4r_incididunt_ut_labore_et?=\n'
+                ' =?utf-8?q?_dol=C3=B4re_magna_aliqua?=',
+            ),
+            # Exactly 75 chars — boundary condition
+            (
+                'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
+                'Lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiu',
+            ),
+            # Unicode on escape boundary
+            (
+                'Lorem ipsum dolor sit amet consectetur adipiscin el\u00eet',
+                '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipiscin_el?=\n'
+                ' =?utf-8?q?=C3=AEt?=',
+            ),
+            # Unicode 1 char too long
+            (
+                'Lorem ipsum dolor sit amet consectetur adipi el\u00eet',
+                '=?utf-8?q?Lorem_ipsum_dolor_sit_amet_consectetur_adipi_el=C3=AE?=\n'
+                ' =?utf-8?q?t?=',
+            ),
+        ],
+    )
     def test_non_address_header(self, hval: str, verify: str) -> None:
         wrapped = wrap_header(('X-Header', hval))
         assert wrapped.decode() == f'X-Header: {verify}'
 
-    @pytest.mark.parametrize('hval,verify', [
-        # Single address
-        ('[email protected]', '[email protected]'),
-        # Two addresses
-        ('[email protected], [email protected]',
-         '[email protected], [email protected]'),
-        # Mixed plain + display name
-        ('[email protected], Foo Bar <[email protected]>',
-         '[email protected], Foo Bar <[email protected]>'),
-        # Mixed Unicode — non-ASCII name gets QP encoded
-        ('[email protected], Foo Bar <[email protected]>, F\u00f4o Baz <[email protected]>',
-         '[email protected], Foo Bar <[email protected]>, \n'
-         ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>'),
-        # Complex with quoted specials
-        ('[email protected], Foo Bar <[email protected]>, '
-         'F\u00f4o Baz <[email protected]>, "Quux, Foo" <[email protected]>',
-         '[email protected], Foo Bar <[email protected]>, \n'
-         ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, '
-         '"Quux, Foo" <[email protected]>'),
-        # Long local part forces line wrap
-        ('01234567890123456789012345678901234567890123456789012345678901@example.org, '
-         '\u00e4 <[email protected]>',
-         '01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
-         ' =?utf-8?q?=C3=A4?= <[email protected]>'),
-        # cpython#100900 — Unicode name with RFC 5322 specials
-        ('[email protected], Foo Bar <[email protected]>, '
-         'F\u00f4o Baz <[email protected]>, "Qu\u00fbx, Foo" <[email protected]>',
-         '[email protected], Foo Bar <[email protected]>, \n'
-         ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, \n'
-         ' =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>'),
-    ])
+    @pytest.mark.parametrize(
+        'hval,verify',
+        [
+            # Single address
+            ('[email protected]', '[email protected]'),
+            # Two addresses
+            ('[email protected], [email protected]', '[email protected], [email protected]'),
+            # Mixed plain + display name
+            (
+                '[email protected], Foo Bar <[email protected]>',
+                '[email protected], Foo Bar <[email protected]>',
+            ),
+            # Mixed Unicode — non-ASCII name gets QP encoded
+            (
+                '[email protected], Foo Bar <[email protected]>, F\u00f4o Baz <[email protected]>',
+                '[email protected], Foo Bar <[email protected]>, \n'
+                ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>',
+            ),
+            # Complex with quoted specials
+            (
+                '[email protected], Foo Bar <[email protected]>, '
+                'F\u00f4o Baz <[email protected]>, "Quux, Foo" <[email protected]>',
+                '[email protected], Foo Bar <[email protected]>, \n'
+                ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, '
+                '"Quux, Foo" <[email protected]>',
+            ),
+            # Long local part forces line wrap
+            (
+                '01234567890123456789012345678901234567890123456789012345678901@example.org, '
+                '\u00e4 <[email protected]>',
+                '01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
+                ' =?utf-8?q?=C3=A4?= <[email protected]>',
+            ),
+            # cpython#100900 — Unicode name with RFC 5322 specials
+            (
+                '[email protected], Foo Bar <[email protected]>, '
+                'F\u00f4o Baz <[email protected]>, "Qu\u00fbx, Foo" <[email protected]>',
+                '[email protected], Foo Bar <[email protected]>, \n'
+                ' =?utf-8?q?F=C3=B4o_Baz?= <[email protected]>, \n'
+                ' =?utf-8?q?Qu=C3=BBx=2C_Foo?= <[email protected]>',
+            ),
+        ],
+    )
     def test_address_header(self, hval: str, verify: str) -> None:
         wrapped = wrap_header(('To', hval))
         assert wrapped.decode() == f'To: {verify}'
 
-    @pytest.mark.parametrize('hval,verify', [
-        # Short message-id
-        ('<[email protected]>',
-         '<[email protected]>'),
-        # Long message-id — unbreakable, stays on one line
-        ('<20240319-very-long-message-id-that-spans-multiple-lines-for-sure'
-         '-because-longer-than-75-characters-abcde123456@longdomain.example.com>',
-         '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure'
-         '-because-longer-than-75-characters-abcde123456@longdomain.example.com>'),
-    ])
+    @pytest.mark.parametrize(
+        'hval,verify',
+        [
+            # Short message-id
+            (
+                '<[email protected]>',
+                '<[email protected]>',
+            ),
+            # Long message-id — unbreakable, stays on one line
+            (
+                '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure'
+                '-because-longer-than-75-characters-abcde123456@longdomain.example.com>',
+                '<20240319-very-long-message-id-that-spans-multiple-lines-for-sure'
+                '-because-longer-than-75-characters-abcde123456@longdomain.example.com>',
+            ),
+        ],
+    )
     def test_message_id(self, hval: str, verify: str) -> None:
         wrapped = wrap_header(('Message-ID', hval))
         assert wrapped.decode() == f'Message-ID: {verify}'
@@ -203,8 +267,9 @@ class TestMinimizeThread:
 
     def test_custom_keep_headers(self, make_msg: MsgFactory) -> None:
         """Callers can override which headers to keep."""
-        msg = make_msg(subject='Test', body='Hello.\n',
-                              date='Mon, 01 Jan 2024 00:00:00 +0000')
+        msg = make_msg(
+            subject='Test', body='Hello.\n', date='Mon, 01 Jan 2024 00:00:00 +0000'
+        )
         result = minimize_thread([msg], keep_headers=('Subject',))
         assert len(result) == 1
         assert result[0]['Subject'] == 'Test'
@@ -329,15 +394,11 @@ class TestMinimizeThread:
         assert 'Second paragraph' not in text
         assert 'My reply here.' in text
 
-    def test_reduce_quote_context_short_quote_untouched(self, make_msg: MsgFactory) -> None:
+    def test_reduce_quote_context_short_quote_untouched(
+        self, make_msg: MsgFactory
+    ) -> None:
         """Quotes with 5 or fewer skippable lines are left alone."""
-        body = (
-            '> Line one.\n'
-            '> Line two.\n'
-            '>\n'
-            '> Last para.\n'
-            'Reply.\n'
-        )
+        body = '> Line one.\n> Line two.\n>\n> Last para.\nReply.\n'
         msg = make_msg(body=body)
         result = minimize_thread([msg], reduce_quote_context=True)
         text = result[0].get_payload()
diff --git a/tests/test_mbox.py b/tests/test_mbox.py
index 9bc2271..7269192 100644
--- a/tests/test_mbox.py
+++ b/tests/test_mbox.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for liblore.mbox."""
+
 from __future__ import annotations
 
 import textwrap
@@ -176,6 +177,7 @@ class TestSplitMboxAsBytes:
     def test_roundtrip_with_split_mbox(self, sample_mbox: bytes) -> None:
         """split_mbox_as_bytes + parse_message should match split_mbox."""
         from liblore.utils import parse_message
+
         raw_chunks = split_mbox_as_bytes(sample_mbox)
         parsed = split_mbox(sample_mbox)
         assert len(raw_chunks) == len(parsed)
@@ -265,6 +267,7 @@ class TestSplitAndDedupeAsBytes:
     def test_roundtrip_with_split_and_dedupe(self, sample_mbox: bytes) -> None:
         """Parsing the bytes output should match split_and_dedupe."""
         from liblore.utils import parse_message
+
         doubled = sample_mbox + sample_mbox
         chunks = split_and_dedupe_as_bytes(doubled)
         msgs = split_and_dedupe(doubled)
diff --git a/tests/test_message.py b/tests/test_message.py
index 947e112..77c4cd4 100644
--- a/tests/test_message.py
+++ b/tests/test_message.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for liblore.message."""
+
 from __future__ import annotations
 
 from email.message import EmailMessage
diff --git a/tests/test_node.py b/tests/test_node.py
index 54d76d8..8f4ccac 100644
--- a/tests/test_node.py
+++ b/tests/test_node.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for liblore.node (LoreNode)."""
+
 from __future__ import annotations
 
 import gzip
@@ -18,6 +19,7 @@ from liblore.node import LoreNode
 # Session management
 # =====================================================================
 
+
 class TestSessionManagement:
     def test_creates_session(self) -> None:
         node = LoreNode()
@@ -115,6 +117,7 @@ class TestSessionManagement:
 # get_mbox_by_msgid / get_mbox_by_query
 # =====================================================================
 
+
 class TestGetMboxByMsgid:
     def test_returns_raw_bytes(self, sample_mbox: bytes) -> None:
         node = LoreNode('https://lore.kernel.org/all')
@@ -243,6 +246,7 @@ class TestGetMboxByQuery:
 # get_thread_by_msgid
 # =====================================================================
 
+
 class TestGetThreadByMsgid:
     def test_full_thread(self, sample_mbox: bytes) -> None:
         node = LoreNode('https://lore.kernel.org/all')
@@ -315,6 +319,7 @@ class TestGetThreadByMsgid:
 # get_thread_updates_since
 # =====================================================================
 
+
 class TestGetThreadUpdatesSince:
     def test_returns_messages(self, sample_mbox: bytes) -> None:
         node = LoreNode('https://lore.kernel.org/all')
@@ -370,7 +375,9 @@ class TestGetThreadUpdatesSince:
 
         since = datetime(2024, 1, 15, 12, 0, 0, tzinfo=timezone.utc)
         msgs = node.get_thread_updates_since(
-            '[email protected]', since, sort=True,
+            '[email protected]',
+            since,
+            sort=True,
         )
         assert len(msgs) >= 1
 
@@ -391,6 +398,7 @@ class TestGetThreadUpdatesSince:
 # get_thread_by_query
 # =====================================================================
 
+
 class TestGetThreadByQuery:
     def test_posts_query(self, sample_mbox: bytes) -> None:
         node = LoreNode('https://lore.kernel.org/all')
@@ -423,6 +431,7 @@ class TestGetThreadByQuery:
 # get_message_by_msgid
 # =====================================================================
 
+
 class TestGetMessageByMsgid:
     def test_fetches_raw(self) -> None:
         node = LoreNode('https://lore.kernel.org/all')
@@ -451,6 +460,7 @@ class TestGetMessageByMsgid:
 # batch_get_thread_by_msgid
 # =====================================================================
 
+
 class TestBatchGetThreadByMsgid:
     def test_returns_ordered_results(self) -> None:
         node = LoreNode()
@@ -482,11 +492,17 @@ class TestBatchGetThreadByMsgid:
 
         with patch('liblore.node.time.sleep'):
             node.batch_get_thread_by_msgid(
-                ['a@x'], strict=False, sort=True, since='20240101',
+                ['a@x'],
+                strict=False,
+                sort=True,
+                since='20240101',
             )
 
         node.get_thread_by_msgid.assert_called_once_with(
-            'a@x', strict=False, sort=True, since='20240101',
+            'a@x',
+            strict=False,
+            sort=True,
+            since='20240101',
         )
 
     def test_sleep_count_matches_gaps(self) -> None:
@@ -514,6 +530,7 @@ class TestBatchGetThreadByMsgid:
 # batch_get_thread_by_query
 # =====================================================================
 
+
 class TestBatchGetThreadByQuery:
     def test_returns_ordered_results(self) -> None:
         node = LoreNode()
@@ -564,6 +581,7 @@ class TestBatchGetThreadByQuery:
 # validate
 # =====================================================================
 
+
 class TestValidate:
     def test_valid_url(self) -> None:
         node = LoreNode('https://lore.kernel.org/lkml')
@@ -603,6 +621,7 @@ class TestValidate:
 # URL fallback
 # =====================================================================
 
+
 class TestFallback:
     """Tests for the fallback_urls feature."""
 
@@ -807,6 +826,7 @@ class TestFallback:
     def test_invalid_fallback_url_no_scheme(self) -> None:
         """Fallback URL without scheme raises LibloreError."""
         from liblore import LibloreError
+
         with pytest.raises(LibloreError, match='Invalid fallback URL'):
             LoreNode(
                 'https://lore.kernel.org/all',
@@ -816,6 +836,7 @@ class TestFallback:
     def test_invalid_fallback_url_with_path(self) -> None:
         """Fallback URL with a path component raises LibloreError."""
         from liblore import LibloreError
+
         with pytest.raises(LibloreError, match='must be a scheme://host origin'):
             LoreNode(
                 'https://lore.kernel.org/all',
@@ -863,6 +884,7 @@ class TestFallback:
 # Origin probing
 # =====================================================================
 
+
 class TestProbeOrigins:
     """Tests for the probe_origins() fastest-mirror feature."""
 
@@ -998,7 +1020,8 @@ class TestProbeOrigins:
             assert h['User-Agent'] == 'myapp/1.0'
 
     def test_auto_probe_triggers_on_first_request(
-        self, sample_mbox: bytes,
+        self,
+        sample_mbox: bytes,
     ) -> None:
         """With auto_probe=True, first _request() triggers probe."""
         node = LoreNode(
@@ -1038,6 +1061,7 @@ class TestProbeOrigins:
         node.set_requests_session(mock_session)
 
         probe_count = 0
+
         def fake_head(url: str, **kwargs: object) -> MagicMock:
             nonlocal probe_count
             probe_count += 1
@@ -1105,10 +1129,12 @@ class TestProbeOrigins:
 
         # Backdate cache file to force expiry
         import glob as glob_mod
+
         for f in glob_mod.glob(os.path.join(cache_dir, '*.lore.cache')):
             os.utime(f, (0, 0))
 
         probe_called = False
+
         def fake_head_2(url: str, **kwargs: object) -> MagicMock:
             nonlocal probe_called
             probe_called = True
@@ -1123,7 +1149,8 @@ class TestProbeOrigins:
         assert probe_called
 
     def test_probe_cache_ignored_when_origins_change(
-        self, tmp_path: object,
+        self,
+        tmp_path: object,
     ) -> None:
         """Cache is ignored when the set of origins differs."""
         cache_dir = str(tmp_path)
@@ -1149,6 +1176,7 @@ class TestProbeOrigins:
         )
 
         probe_called = False
+
         def fake_head_2(url: str, **kwargs: object) -> MagicMock:
             nonlocal probe_called
             probe_called = True
@@ -1207,6 +1235,7 @@ class TestProbeOrigins:
 # Git config integration
 # =====================================================================
 
+
 class TestFromGitConfig:
     """Tests for LoreNode.from_git_config() with legacy [lore] section.
 
@@ -1226,8 +1255,10 @@ class TestFromGitConfig:
             'probettl': '7200',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._all_origins == [
@@ -1246,8 +1277,10 @@ class TestFromGitConfig:
             'autoprobe': 'true',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config(
                 fallback_urls=['https://explicit.example.com'],
                 auto_probe=False,
@@ -1261,16 +1294,20 @@ class TestFromGitConfig:
 
     def test_git_not_installed(self) -> None:
         """Works fine when both config helpers return empty."""
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value={}):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value={}),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._all_origins == ['https://lore.kernel.org']
 
     def test_no_config_keys(self) -> None:
         """Works fine when no lore.* keys exist in git config."""
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value={}):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value={}),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._all_origins == ['https://lore.kernel.org']
@@ -1280,16 +1317,20 @@ class TestFromGitConfig:
         """Non-numeric lore.probetimeout is silently ignored."""
         gitcfg: dict[str, str | list[str]] = {'probetimeout': 'notanumber'}
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._probe_timeout == 5.0  # default
 
     def test_custom_url_passed_through(self) -> None:
         """The url argument is forwarded to __init__."""
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value={}):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value={}),
+        ):
             node = LoreNode.from_git_config(
                 url='https://my-inbox.example.com/lists',
             )
@@ -1302,8 +1343,10 @@ class TestFromGitConfig:
             'useragentplus': '550e8400-e29b-41d4',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._user_agent_plus == '550e8400-e29b-41d4'
@@ -1317,8 +1360,10 @@ class TestFromGitConfig:
             'useragentplus': 'from-git-config',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config()
 
         node.set_user_agent('myapp', '1.0', plus='explicit')
@@ -1336,8 +1381,10 @@ class TestFromGitConfig:
             'useragentplus': 'myuuid',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=gitcfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=gitcfg),
+        ):
             node = LoreNode.from_git_config()
 
         node.set_user_agent('bugspray', '0.3')
@@ -1426,7 +1473,9 @@ class TestGetSubsectionConfig:
         )
         with patch('liblore.node.subprocess.run', return_value=mock_result):
             cfg = _get_subsection_config(
-                'liblore', 'https://lore.kernel.org', multivals=['fallback'],
+                'liblore',
+                'https://lore.kernel.org',
+                multivals=['fallback'],
             )
 
         assert cfg == {
@@ -1444,12 +1493,12 @@ class TestGetSubsectionConfig:
 
         mock_result = MagicMock()
         mock_result.returncode = 0
-        mock_result.stdout = (
-            'liblore.https://subspace.kernel.org.fallback\nhttps://mirror.example.com\x00'
-        )
+        mock_result.stdout = 'liblore.https://subspace.kernel.org.fallback\nhttps://mirror.example.com\x00'
         with patch('liblore.node.subprocess.run', return_value=mock_result):
             cfg = _get_subsection_config(
-                'liblore', 'https://subspace.kernel.org', multivals=['fallback'],
+                'liblore',
+                'https://subspace.kernel.org',
+                multivals=['fallback'],
             )
 
         assert cfg == {
@@ -1465,7 +1514,8 @@ class TestGetSubsectionConfig:
         mock_result.stdout = ''
         with patch('liblore.node.subprocess.run', return_value=mock_result):
             cfg = _get_subsection_config(
-                'liblore', 'https://nonexistent.example.com',
+                'liblore',
+                'https://nonexistent.example.com',
             )
 
         assert cfg == {}
@@ -1479,7 +1529,8 @@ class TestGetSubsectionConfig:
             side_effect=FileNotFoundError('git not found'),
         ):
             cfg = _get_subsection_config(
-                'liblore', 'https://lore.kernel.org',
+                'liblore',
+                'https://lore.kernel.org',
             )
 
         assert cfg == {}
@@ -1496,8 +1547,10 @@ class TestFromGitConfigSubsections:
             'useragentplus': 'subsection-uuid',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value=subsection_cfg), \
-             patch('liblore.node._get_config_from_git', return_value={}) as mock_legacy:
+        with (
+            patch('liblore.node._get_subsection_config', return_value=subsection_cfg),
+            patch('liblore.node._get_config_from_git', return_value={}) as mock_legacy,
+        ):
             node = LoreNode.from_git_config()
 
         assert node._all_origins == [
@@ -1516,8 +1569,10 @@ class TestFromGitConfigSubsections:
             'autoprobe': 'true',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=legacy_cfg):
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch('liblore.node._get_config_from_git', return_value=legacy_cfg),
+        ):
             node = LoreNode.from_git_config()
 
         assert node._all_origins == [
@@ -1533,8 +1588,12 @@ class TestFromGitConfigSubsections:
             'autoprobe': 'true',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value={}), \
-             patch('liblore.node._get_config_from_git', return_value=legacy_cfg) as mock_legacy:
+        with (
+            patch('liblore.node._get_subsection_config', return_value={}),
+            patch(
+                'liblore.node._get_config_from_git', return_value=legacy_cfg
+            ) as mock_legacy,
+        ):
             node = LoreNode.from_git_config(
                 url='https://subspace.kernel.org/_lists/helpdesk',
             )
@@ -1552,8 +1611,10 @@ class TestFromGitConfigSubsections:
             'useragentplus': 'subspace-token',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value=subsection_cfg), \
-             patch('liblore.node._get_config_from_git') as mock_legacy:
+        with (
+            patch('liblore.node._get_subsection_config', return_value=subsection_cfg),
+            patch('liblore.node._get_config_from_git') as mock_legacy,
+        ):
             node = LoreNode.from_git_config(
                 url='https://subspace.kernel.org/_lists/helpdesk',
             )
@@ -1576,8 +1637,12 @@ class TestFromGitConfigSubsections:
             'useragentplus': 'old-uuid',
         }
 
-        with patch('liblore.node._get_subsection_config', return_value=subsection_cfg), \
-             patch('liblore.node._get_config_from_git', return_value=legacy_cfg) as mock_legacy:
+        with (
+            patch('liblore.node._get_subsection_config', return_value=subsection_cfg),
+            patch(
+                'liblore.node._get_config_from_git', return_value=legacy_cfg
+            ) as mock_legacy,
+        ):
             node = LoreNode.from_git_config()
 
         # Subsection wins
@@ -1613,6 +1678,7 @@ class TestFromGitConfigSubsections:
 # Public API: request()
 # =====================================================================
 
+
 class TestRequest:
     """Tests for the public request() method."""
 
@@ -1670,7 +1736,8 @@ class TestRequest:
         node.set_requests_session(mock_session)
 
         node.request(
-            'GET', 'https://lore.kernel.org/manifest.js.gz',
+            'GET',
+            'https://lore.kernel.org/manifest.js.gz',
             timeout=30,
         )
         _, kwargs = mock_session.get.call_args
@@ -1681,6 +1748,7 @@ class TestRequest:
 # Public API: user_agent_plus property
 # =====================================================================
 
+
 class TestUserAgentPlusProperty:
     """Tests for the user_agent_plus read-only property."""
 
@@ -1710,6 +1778,7 @@ class TestUserAgentPlusProperty:
 # Public API: origins property
 # =====================================================================
 
+
 class TestOriginsProperty:
     """Tests for the origins read-only property."""
 
@@ -1763,6 +1832,7 @@ class TestOriginsProperty:
 # Public API: canonical_origin property
 # =====================================================================
 
+
 class TestCanonicalOriginProperty:
     """Tests for the canonical_origin read-only property."""
 
diff --git a/tests/test_thread.py b/tests/test_thread.py
index 62a9a1e..d835373 100644
--- a/tests/test_thread.py
+++ b/tests/test_thread.py
@@ -1,5 +1,6 @@
 # SPDX-License-Identifier: GPL-2.0-or-later
 """Tests for liblore.thread."""
+
 from __future__ import annotations
 
 from conftest import MsgFactory
diff --git a/tools/b4-ci-check.py b/tools/b4-ci-check.py
index 1bc5de1..563e018 100644
--- a/tools/b4-ci-check.py
+++ b/tools/b4-ci-check.py
@@ -51,6 +51,12 @@ def main() -> None:
     repo_root = Path(__file__).resolve().parent.parent
     os.chdir(repo_root)
     checks = [
+        Check(
+            tool='ruff format',
+            args=['format', '--check'],
+            pass_summary='ruff format passed',
+            run=run_subprocess('ruff'),
+        ),
         Check(
             tool='ruff lint',
             args=['check'],

-- 
2.53.0
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.