From: Tamir Duberstein <tamird@gmail.com>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
Tamir Duberstein <tamird@gmail.com>
Subject: [PATCH 04/14] Add ruff format check to CI
Date: Fri, 10 Apr 2026 18:37:55 -0400 [thread overview]
Message-ID: <20260410-harden-type-checking-v1-4-fcf314d9d748@gmail.com> (raw)
In-Reply-To: <20260410-harden-type-checking-v1-0-fcf314d9d748@gmail.com>
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 <tamird@gmail.com>
---
pyproject.toml | 3 +
src/liblore/__init__.py | 1 +
src/liblore/node.py | 61 ++++++-----
src/liblore/utils.py | 86 +++++++++++-----
tests/conftest.py | 1 +
| 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
--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('test@example.com', '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', [
- ([('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')],
- 'foo@example.com, Foo Bar <bar@example.com>', True),
- ([('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')],
- 'foo@example.com, "Foo, Bar" <bar@example.com>', True),
- ([('', 'foo@example.com'), ('F\u00f4o, Bar', 'bar@example.com')],
- 'foo@example.com, "F\u00f4o, Bar" <bar@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com')],
- 'foo@example.com, Qu\u00fbx Foo <quux@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')],
- 'foo@example.com, "Qu\u00fbx, Foo" <quux@example.com>', True),
- ([('', 'foo@example.com'), ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com')],
- 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>', False),
- ])
- def test_format_addrs(self, pairs: list[tuple[str, str]],
- verify: str, clean: bool) -> None:
+ @pytest.mark.parametrize(
+ 'pairs,verify,clean',
+ [
+ (
+ [('', 'foo@example.com'), ('Foo Bar', 'bar@example.com')],
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ True,
+ ),
+ (
+ [('', 'foo@example.com'), ('Foo, Bar', 'bar@example.com')],
+ 'foo@example.com, "Foo, Bar" <bar@example.com>',
+ True,
+ ),
+ (
+ [('', 'foo@example.com'), ('F\u00f4o, Bar', 'bar@example.com')],
+ 'foo@example.com, "F\u00f4o, Bar" <bar@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, Qu\u00fbx Foo <quux@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, "Qu\u00fbx, Foo" <quux@example.com>',
+ True,
+ ),
+ (
+ [
+ ('', 'foo@example.com'),
+ ('=?utf-8?q?Qu=C3=BBx=2C_Foo?=', 'quux@example.com'),
+ ],
+ 'foo@example.com, =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>',
+ 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
- ('foo@example.com', 'foo@example.com'),
- # Two addresses
- ('foo@example.com, bar@example.com',
- 'foo@example.com, bar@example.com'),
- # Mixed plain + display name
- ('foo@example.com, Foo Bar <bar@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>'),
- # Mixed Unicode — non-ASCII name gets QP encoded
- ('foo@example.com, Foo Bar <bar@example.com>, F\u00f4o Baz <baz@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, \n'
- ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>'),
- # Complex with quoted specials
- ('foo@example.com, Foo Bar <bar@example.com>, '
- 'F\u00f4o Baz <baz@example.com>, "Quux, Foo" <quux@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, \n'
- ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, '
- '"Quux, Foo" <quux@example.com>'),
- # Long local part forces line wrap
- ('01234567890123456789012345678901234567890123456789012345678901@example.org, '
- '\u00e4 <foo@example.org>',
- '01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
- ' =?utf-8?q?=C3=A4?= <foo@example.org>'),
- # cpython#100900 — Unicode name with RFC 5322 specials
- ('foo@example.com, Foo Bar <bar@example.com>, '
- 'F\u00f4o Baz <baz@example.com>, "Qu\u00fbx, Foo" <quux@example.com>',
- 'foo@example.com, Foo Bar <bar@example.com>, \n'
- ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, \n'
- ' =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>'),
- ])
+ @pytest.mark.parametrize(
+ 'hval,verify',
+ [
+ # Single address
+ ('foo@example.com', 'foo@example.com'),
+ # Two addresses
+ ('foo@example.com, bar@example.com', 'foo@example.com, bar@example.com'),
+ # Mixed plain + display name
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>',
+ ),
+ # Mixed Unicode — non-ASCII name gets QP encoded
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, F\u00f4o Baz <baz@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, \n'
+ ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>',
+ ),
+ # Complex with quoted specials
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, '
+ 'F\u00f4o Baz <baz@example.com>, "Quux, Foo" <quux@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, \n'
+ ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, '
+ '"Quux, Foo" <quux@example.com>',
+ ),
+ # Long local part forces line wrap
+ (
+ '01234567890123456789012345678901234567890123456789012345678901@example.org, '
+ '\u00e4 <foo@example.org>',
+ '01234567890123456789012345678901234567890123456789012345678901@example.org, \n'
+ ' =?utf-8?q?=C3=A4?= <foo@example.org>',
+ ),
+ # cpython#100900 — Unicode name with RFC 5322 specials
+ (
+ 'foo@example.com, Foo Bar <bar@example.com>, '
+ 'F\u00f4o Baz <baz@example.com>, "Qu\u00fbx, Foo" <quux@example.com>',
+ 'foo@example.com, Foo Bar <bar@example.com>, \n'
+ ' =?utf-8?q?F=C3=B4o_Baz?= <baz@example.com>, \n'
+ ' =?utf-8?q?Qu=C3=BBx=2C_Foo?= <quux@example.com>',
+ ),
+ ],
+ )
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
- ('<20240319-short-message-id@example.com>',
- '<20240319-short-message-id@example.com>'),
- # 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
+ (
+ '<20240319-short-message-id@example.com>',
+ '<20240319-short-message-id@example.com>',
+ ),
+ # 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(
- 'first@example.com', since, sort=True,
+ 'first@example.com',
+ 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
next prev parent reply other threads:[~2026-04-10 22:38 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-04-10 22:37 [PATCH 00/14] Harden local type checking and test mocking Tamir Duberstein
2026-04-10 22:37 ` [PATCH 01/14] Add b4 CI checks and mypy suppressions Tamir Duberstein
2026-04-10 22:37 ` [PATCH 02/14] Type make_msg and drop test suppressions Tamir Duberstein
2026-04-10 22:37 ` [PATCH 03/14] Add ruff import checks to b4 CI Tamir Duberstein
2026-04-10 22:37 ` Tamir Duberstein [this message]
2026-04-10 22:37 ` [PATCH 05/14] Add pyright strict checks to CI Tamir Duberstein
2026-04-10 22:37 ` [PATCH 06/14] Replace HTTP session mocks with responses Tamir Duberstein
2026-04-10 22:37 ` [PATCH 07/14] Add ty checks to CI Tamir Duberstein
2026-04-10 22:37 ` [PATCH 08/14] Drop redundant read-only property test Tamir Duberstein
2026-04-10 22:38 ` [PATCH 09/14] Type from_git_config keyword arguments Tamir Duberstein
2026-04-10 22:38 ` [PATCH 10/14] Add authheaders stub and typed callable Tamir Duberstein
2026-04-10 22:38 ` [PATCH 11/14] Replace batch mocks with subclasses Tamir Duberstein
2026-04-10 22:38 ` [PATCH 12/14] Use CompletedProcess in git config tests Tamir Duberstein
2026-04-10 22:38 ` [PATCH 13/14] Update README for uv-based dev checks Tamir Duberstein
2026-04-10 22:38 ` [PATCH 14/14] Add b4 send configuration Tamir Duberstein
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260410-harden-type-checking-v1-4-fcf314d9d748@gmail.com \
--to=tamird@gmail.com \
--cc=konstantin@linuxfoundation.org \
--cc=tools@kernel.org \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox