* [PATCH b4 01/12] Configure ruff format with single quotes
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 02/12] Fix ruff check warnings Tamir Duberstein
` (11 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
This allows me to format modified code as I go without changing
pervasive style.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
pyproject.toml | 3 +++
1 file changed, 3 insertions(+)
diff --git a/pyproject.toml b/pyproject.toml
index b418bca..a324583 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -81,6 +81,9 @@ files = [
{filename = "src/b4/man/b4.1"},
]
+[tool.ruff.format]
+quote-style = "single"
+
[tool.ruff.lint]
select = [
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 02/12] Fix ruff check warnings
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 01/12] Configure ruff format with single quotes Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 03/12] Use ruff to sort imports Tamir Duberstein
` (10 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Mark example-only variables as intentionally unused so ruff can check
the script without changing its illustrative structure.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/review-ci-example.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/misc/review-ci-example.py b/misc/review-ci-example.py
index e5837eb..cbac2ae 100755
--- a/misc/review-ci-example.py
+++ b/misc/review-ci-example.py
@@ -43,7 +43,7 @@ import sys
def main() -> None:
msg = email.message_from_binary_file(sys.stdin.buffer)
- subject = msg.get('subject', '(no subject)')
+ subject = msg.get('subject', '(no subject)') # noqa: F841
msgid = msg.get('message-id', '').strip('<> ')
# Example: read tracking data for commit-based CI lookups
@@ -53,7 +53,7 @@ def main() -> None:
tracking = json.load(fp)
branch_tips = tracking.get('series', {}).get('branch-tips', [])
else:
- branch_tips = []
+ branch_tips = [] # noqa: F841
# Seed the RNG with the message-id so results are stable across
# repeated runs of the same message (simulates cached CI results).
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 03/12] Use ruff to sort imports
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 01/12] Configure ruff format with single quotes Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 02/12] Fix ruff check warnings Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 04/12] Import dependencies unconditionally Tamir Duberstein
` (9 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
All edits apart from pyproject.toml made by `ruff check --fix`.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/send-receive.py | 30 +++++++++---------
pyproject.toml | 1 +
src/b4/__init__.py | 65 ++++++++++++++++++++++----------------
src/b4/bugs/__init__.py | 8 ++---
src/b4/bugs/_import.py | 3 +-
src/b4/bugs/_tui.py | 9 +++---
src/b4/command.py | 4 +--
src/b4/diff.py | 16 +++++-----
src/b4/dig.py | 13 ++++----
src/b4/ez.py | 33 +++++++++----------
src/b4/kr.py | 2 +-
src/b4/mbox.py | 28 ++++++++--------
src/b4/pr.py | 24 +++++++-------
src/b4/review/__init__.py | 29 +++++++++++------
src/b4/review/_review.py | 3 +-
src/b4/review/checks.py | 1 -
src/b4/review/messages.py | 1 -
src/b4/review/tracking.py | 4 +--
src/b4/review_tui/__init__.py | 20 ++++++++----
src/b4/review_tui/_common.py | 45 ++++++++++++++++++++++----
src/b4/review_tui/_entry.py | 3 +-
src/b4/review_tui/_lite_app.py | 19 +++++------
src/b4/review_tui/_modals.py | 42 ++++++++++++++++--------
src/b4/review_tui/_pw_app.py | 28 ++++++++++------
src/b4/review_tui/_review_app.py | 53 ++++++++++++++++++++-----------
src/b4/review_tui/_tracking_app.py | 53 ++++++++++++++++++++++---------
src/b4/tui/_common.py | 8 ++---
src/b4/tui/_modals.py | 11 +++++--
src/b4/ty.py | 19 +++++------
src/tests/conftest.py | 7 ++--
src/tests/test___init__.py | 9 +++---
src/tests/test_ez.py | 11 ++++---
src/tests/test_mbox.py | 11 ++++---
src/tests/test_patatt.py | 3 +-
src/tests/test_rethread.py | 4 +--
src/tests/test_review.py | 4 +--
src/tests/test_review_checks.py | 1 -
src/tests/test_review_show_info.py | 5 ++-
src/tests/test_review_tracking.py | 2 +-
src/tests/test_three_way_merge.py | 7 ++--
src/tests/test_tui_bugs.py | 1 -
src/tests/test_tui_modals.py | 6 ++--
src/tests/test_tui_review.py | 6 ++--
src/tests/test_tui_tracking.py | 13 +++-----
44 files changed, 384 insertions(+), 281 deletions(-)
diff --git a/misc/send-receive.py b/misc/send-receive.py
index a3dd893..35c5e99 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -1,29 +1,29 @@
#!/usr/bin/env python3
-import falcon
-import os
-import sys
-import logging
-import logging.handlers
-import json
-import sqlalchemy as sa
-import patatt
-import smtplib
+import copy
import email
import email.header
import email.policy
import email.quoprimime
+import json
+import logging
+import logging.handlers
+import os
import re
-import ezpi
-import copy
+import smtplib
+import sys
import textwrap
-
from configparser import ConfigParser, ExtendedInterpolation
+from email import charset, utils
from string import Template
-from email import utils
-from typing import Tuple, Union, List
+from typing import List, Tuple, Union
+
+import ezpi
+import falcon
+import sqlalchemy as sa
+
+import patatt
-from email import charset
charset.add_charset('utf-8', None)
emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None)
diff --git a/pyproject.toml b/pyproject.toml
index a324583..4fad7da 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -91,6 +91,7 @@ select = [
"B904", # https://docs.astral.sh/ruff/rules/raise-without-from-err/
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
+ "I", # https://docs.astral.sh/ruff/rules/#isort-i
"PERF102", # https://docs.astral.sh/ruff/rules/incorrect-dict-iterator/
"PGH004", # https://docs.astral.sh/ruff/rules/blanket-noqa/
"PIE790", # https://docs.astral.sh/ruff/rules/unnecessary-placeholder/
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index a298c1d..7cc3a93 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -1,48 +1,59 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2020 by the Linux Foundation
-import subprocess
-import logging
-import hashlib
-import re
-import sys
-import gzip
-import os
-import fnmatch
+import argparse
+import copy
+import datetime
import email.generator
import email.header
import email.parser
import email.policy
import email.quoprimime
import email.utils
-import tempfile
+import fnmatch
+import gzip
+import hashlib
+import io
+import json
+import logging
+import mailbox
+import os
import pathlib
-import argparse
-import smtplib
+import pwd
+import re
import shlex
+import shutil
+import smtplib
+import subprocess
+import sys
+import tempfile
import textwrap
-import json
-
-import urllib.parse
-import datetime
import time
-import copy
-import shutil
-import mailbox
-import pwd
-import io
+import urllib.parse
+from contextlib import contextmanager
+from pathlib import Path
+from typing import (
+ Any,
+ BinaryIO,
+ Dict,
+ Generator,
+ Iterator,
+ List,
+ Literal,
+ Optional,
+ Sequence,
+ Set,
+ Tuple,
+ TypeVar,
+ Union,
+ overload,
+)
import requests
-from pathlib import Path
-from contextlib import contextmanager
-from typing import Optional, Tuple, Set, List, BinaryIO, Union, Sequence, Literal, Iterator, Dict, \
- TypeVar, overload, Generator, Any
-
ConfigDictT = Dict[str, Union[str, List[str], None]]
-from email.message import EmailMessage
-
from email import charset
+from email.message import EmailMessage
charset.add_charset('utf-8', None)
# Policy we use for saving mail locally
diff --git a/src/b4/bugs/__init__.py b/src/b4/bugs/__init__.py
index dd28b5a..e48424a 100644
--- a/src/b4/bugs/__init__.py
+++ b/src/b4/bugs/__init__.py
@@ -4,16 +4,16 @@
# Copyright (C) 2020 by the Linux Foundation
"""b4 bugs: manage bug reports from mailing list threads."""
import argparse
-import logging
-import sys
-
import json
+import logging
import shutil
+import sys
-import b4
from ezgb import BugNotFoundError, GitBugRepo, Status
from ezgb._git import git_bug_cli
+import b4
+
logger = logging.getLogger('b4')
diff --git a/src/b4/bugs/_import.py b/src/b4/bugs/_import.py
index 954ebec..3b05037 100644
--- a/src/b4/bugs/_import.py
+++ b/src/b4/bugs/_import.py
@@ -9,9 +9,10 @@ import re
from email.message import EmailMessage
from typing import Optional
+from ezgb import Bug, GitBugRepo
+
import b4
import b4.mbox
-from ezgb import Bug, GitBugRepo
logger = logging.getLogger('b4')
diff --git a/src/b4/bugs/_tui.py b/src/b4/bugs/_tui.py
index aa59cea..eae7f88 100644
--- a/src/b4/bugs/_tui.py
+++ b/src/b4/bugs/_tui.py
@@ -14,14 +14,14 @@ from typing import TYPE_CHECKING, Optional, Union
if TYPE_CHECKING:
from textual.events import Key
-from textual.events import Click, MouseScrollDown, MouseScrollUp
-
+from ezgb import Bug, BugSummary, Comment, GitBugRepo, Status
from rich import box
from rich.panel import Panel
from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
+from textual.events import Click, MouseScrollDown, MouseScrollUp
from textual.screen import ModalScreen
from textual.suggester import SuggestFromList
from textual.widgets import (
@@ -34,6 +34,8 @@ from textual.widgets import (
)
from textual.worker import Worker, WorkerState
+import b4
+from b4.bugs._import import is_comment_removed, make_tombstone, parse_comment_header
from b4.tui import (
ActionScreen,
ConfirmScreen,
@@ -47,9 +49,6 @@ from b4.tui import (
resolve_styles,
reviewer_colours,
)
-import b4
-from b4.bugs._import import is_comment_removed, make_tombstone, parse_comment_header
-from ezgb import Bug, BugSummary, Comment, GitBugRepo, Status
# Union type for items that can appear in the bug list.
# BugSummary is used for the fast initial load; full Bug on demand.
diff --git a/src/b4/command.py b/src/b4/command.py
index 5e85bdf..a70a986 100644
--- a/src/b4/command.py
+++ b/src/b4/command.py
@@ -7,11 +7,11 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import argparse
import logging
-import b4
import sys
-
from typing import Any, Optional, Sequence, Union
+import b4
+
logger = b4.logger
diff --git a/src/b4/diff.py b/src/b4/diff.py
index 934b9ac..8045243 100644
--- a/src/b4/diff.py
+++ b/src/b4/diff.py
@@ -5,19 +5,19 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import os
-import sys
-import b4
-import b4.mbox
+import argparse
import email
import email.parser
-import shutil
+import os
import pathlib
-import argparse
import shlex
-
-from typing import Tuple, Optional, List
+import shutil
+import sys
from email.message import EmailMessage
+from typing import List, Optional, Tuple
+
+import b4
+import b4.mbox
logger = b4.logger
diff --git a/src/b4/dig.py b/src/b4/dig.py
index f13deac..b3d637d 100644
--- a/src/b4/dig.py
+++ b/src/b4/dig.py
@@ -5,19 +5,18 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import sys
-import b4
import argparse
+import datetime
+import email.utils
import re
+import sys
import urllib.parse
-import datetime
+from email.message import EmailMessage
+from typing import List, Optional, Set
+import b4
import b4.mbox
-from email.message import EmailMessage
-import email.utils
-from typing import List, Set, Optional
-
logger = b4.logger
# Supported diff algorithms we will try to match
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 94b8686..e69a106 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -5,31 +5,31 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import os
-import sys
-import b4
-import re
import argparse
-import uuid
-import time
+import base64
import datetime
-import json
-import shlex
import email
import email.policy
import email.utils
-import pathlib
-import base64
-import textwrap
import gzip
+import hashlib
import io
+import json
+import os
+import pathlib
+import re
+import shlex
+import sys
import tarfile
-import hashlib
+import textwrap
+import time
import urllib.parse
-
-from typing import Any, Optional, Tuple, List, Union, Dict, Set
-from string import Template
+import uuid
from email.message import EmailMessage
+from string import Template
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
+
+import b4
try:
import patatt
@@ -44,6 +44,7 @@ except ModuleNotFoundError:
can_gfr = False
import importlib.util
+
can_codespell = importlib.util.find_spec('codespell_lib') is not None
logger = b4.logger
@@ -216,8 +217,8 @@ def auth_new() -> None:
sys.exit(1)
pubkey = out.decode()
elif algo == 'ed25519':
- from nacl.signing import SigningKey
from nacl.encoding import Base64Encoder
+ from nacl.signing import SigningKey
sk = SigningKey(keydata.encode(), encoder=Base64Encoder)
pubkey = base64.b64encode(sk.verify_key.encode()).decode()
else:
diff --git a/src/b4/kr.py b/src/b4/kr.py
index 13f24c7..8bbfe26 100644
--- a/src/b4/kr.py
+++ b/src/b4/kr.py
@@ -7,9 +7,9 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import argparse
import os
-import sys
import pathlib
import re
+import sys
import b4
diff --git a/src/b4/mbox.py b/src/b4/mbox.py
index 2164fcc..624a2f3 100644
--- a/src/b4/mbox.py
+++ b/src/b4/mbox.py
@@ -5,28 +5,26 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import os
-import sys
-import mailbox
+import argparse
import email
-import email.utils
import email.parser
-import re
-import time
-import json
+import email.utils
import fnmatch
-import shutil
-import pathlib
import io
+import json
+import mailbox
+import os
+import pathlib
+import re
import shlex
-import argparse
-
-import b4
-
-from typing import Any, Optional, Union, List, Set, Dict, Tuple
+import shutil
+import sys
+import time
+from email.message import EmailMessage
from string import Template
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
-from email.message import EmailMessage
+import b4
logger = b4.logger
diff --git a/src/b4/pr.py b/src/b4/pr.py
index 5969a0d..cb2ca76 100644
--- a/src/b4/pr.py
+++ b/src/b4/pr.py
@@ -5,26 +5,24 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import os
-import sys
-import tempfile
-
-import b4
-import re
-import json
+import argparse
import email
import email.message
import email.parser
import email.utils
-import argparse
-
+import json
+import os
+import re
+import sys
+import tempfile
import urllib.parse
-import requests
-
from datetime import datetime, timezone
+from email import charset, utils
+from typing import List, Optional
-from email import utils, charset
-from typing import Optional, List
+import requests
+
+import b4
charset.add_charset('utf-8', None)
diff --git a/src/b4/review/__init__.py b/src/b4/review/__init__.py
index 4f64451..df1e39b 100644
--- a/src/b4/review/__init__.py
+++ b/src/b4/review/__init__.py
@@ -1,21 +1,30 @@
# Re-export everything from the original review module
from b4.review._review import * # noqa: F403
from b4.review._review import (
- _retrieve_messages, retrieve_series_messages, _get_lore_series,
- _collect_followups, _collect_reply_headers,
- _get_my_review, _ensure_my_review, _cleanup_review,
- _get_patch_state, _set_patch_state,
- _resolve_comment_positions,
- _render_quoted_diff_with_comments, _extract_editor_comments,
- _clear_other_comments, _strip_subject,
- _build_reply_from_comments, _ensure_trailers_in_body,
+ _build_reply_from_comments,
_build_review_email,
- _integrate_agent_reviews,
+ _cleanup_review,
+ _clear_other_comments,
+ _collect_followups,
+ _collect_reply_headers,
+ _ensure_my_review,
+ _ensure_trailers_in_body,
_extract_comments_from_quoted_reply,
- _integrate_sashiko_reviews,
+ _extract_editor_comments,
+ _get_lore_series,
+ _get_my_review,
+ _get_patch_state,
+ _integrate_agent_reviews,
_integrate_followup_inline_comments,
+ _integrate_sashiko_reviews,
_prepare_review_session,
+ _render_quoted_diff_with_comments,
+ _resolve_comment_positions,
+ _retrieve_messages,
+ _set_patch_state,
_should_promote_waiting,
+ _strip_subject,
+ retrieve_series_messages,
)
# Tell mypy these private symbols are intentionally re-exported
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index dbbf4b5..3d5490b 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -15,13 +15,12 @@ import re
import shutil
import sys
import urllib.parse
+from typing import Any, Dict, List, Optional, Set, Tuple, Union
import b4
import b4.mbox
import b4.review.tracking
-from typing import Dict, Any, List, Optional, Set, Tuple, Union
-
logger = b4.logger
REVIEW_MAGIC_MARKER = '--- b4-review-tracking ---'
diff --git a/src/b4/review/checks.py b/src/b4/review/checks.py
index 65ee0ca..2ea5027 100644
--- a/src/b4/review/checks.py
+++ b/src/b4/review/checks.py
@@ -12,7 +12,6 @@ import os
import pathlib
import shlex
import sqlite3
-
from email.message import EmailMessage
from typing import Any, Dict, List, Optional, Tuple
diff --git a/src/b4/review/messages.py b/src/b4/review/messages.py
index 344d36b..3a0098c 100644
--- a/src/b4/review/messages.py
+++ b/src/b4/review/messages.py
@@ -8,7 +8,6 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import os
import pathlib
import sqlite3
-
from typing import Dict, List, Optional
import b4
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 76287e2..e66cfd8 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -15,12 +15,11 @@ import pathlib
import sqlite3
import sys
import urllib.parse
+from typing import Any, Dict, List, Optional, Set, Tuple
import b4
import b4.mbox
-from typing import Any, Dict, List, Optional, Set, Tuple
-
logger = b4.logger
REVIEW_METADATA_DIR = 'b4-review'
@@ -1170,6 +1169,7 @@ def _store_thread_blob(topdir: str, change_id: str,
# Local import first — avoids circular deps AND prevents UnboundLocalError
# that would occur if `import b4.review` appeared after a `b4.xxx` call.
import io
+
import b4.review as _b4_review
buf = io.BytesIO()
diff --git a/src/b4/review_tui/__init__.py b/src/b4/review_tui/__init__.py
index 47f3f93..68548e6 100644
--- a/src/b4/review_tui/__init__.py
+++ b/src/b4/review_tui/__init__.py
@@ -1,15 +1,21 @@
from b4.review_tui._common import (
- logger, PATCH_STATE_MARKERS,
- resolve_styles, reviewer_colours,
+ PATCH_STATE_MARKERS,
+ _addrs_to_lines,
+ _lines_to_header,
+ _validate_addrs,
gather_attestation_info,
- _addrs_to_lines, _lines_to_header, _validate_addrs,
+ logger,
+ resolve_styles,
+ reviewer_colours,
)
-from b4.review_tui._review_app import ReviewApp
-from b4.review_tui._tracking_app import TrackingApp
-from b4.review_tui._pw_app import PwApp
from b4.review_tui._entry import (
- run_branch_tui, run_pw_tui, run_tracking_tui,
+ run_branch_tui,
+ run_pw_tui,
+ run_tracking_tui,
)
+from b4.review_tui._pw_app import PwApp
+from b4.review_tui._review_app import ReviewApp
+from b4.review_tui._tracking_app import TrackingApp
__all__ = [
'logger', 'PATCH_STATE_MARKERS',
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index e0bce08..f30405d 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -12,20 +12,19 @@ import email.utils
import json
import os
import tempfile
-
from typing import Any, Dict, List, Optional, Set, Tuple
-import b4
-import b4.mbox
-import b4.review
-import b4.review.tracking
-
-from textual.widgets import RichLog
from rich import box
from rich.padding import Padding
from rich.panel import Panel
from rich.rule import Rule
from rich.text import Text
+from textual.widgets import RichLog
+
+import b4
+import b4.mbox
+import b4.review
+import b4.review.tracking
logger = b4.logger
@@ -78,21 +77,53 @@ CI_CHECK_LABELS = {
# -- Re-exported from b4.tui (canonical home for shared TUI utilities) --------
from b4.tui._common import (
JKListNavMixin as JKListNavMixin,
+)
+from b4.tui._common import (
SeparatedFooter as SeparatedFooter,
+)
+from b4.tui._common import (
_addrs_to_lines as _addrs_to_lines,
+)
+from b4.tui._common import (
_fix_ansi_theme as _fix_ansi_theme,
+)
+from b4.tui._common import (
_lines_to_header as _lines_to_header,
+)
+from b4.tui._common import (
_quiet_worker as _quiet_worker,
+)
+from b4.tui._common import (
_suspend_to_shell as _suspend_to_shell,
+)
+from b4.tui._common import (
_to_rich_color as _to_rich_color,
+)
+from b4.tui._common import (
_validate_addrs as _validate_addrs,
+)
+from b4.tui._common import (
_wait_for_enter as _wait_for_enter,
+)
+from b4.tui._common import (
ci_check_styles as ci_check_styles,
+)
+from b4.tui._common import (
ci_markup as ci_markup,
+)
+from b4.tui._common import (
ci_styles as ci_styles,
+)
+from b4.tui._common import (
display_width as display_width,
+)
+from b4.tui._common import (
pad_display as pad_display,
+)
+from b4.tui._common import (
resolve_styles as resolve_styles,
+)
+from b4.tui._common import (
reviewer_colours as reviewer_colours,
)
diff --git a/src/b4/review_tui/_entry.py b/src/b4/review_tui/_entry.py
index 717d1eb..68a48af 100644
--- a/src/b4/review_tui/_entry.py
+++ b/src/b4/review_tui/_entry.py
@@ -10,11 +10,10 @@ from typing import Any, Dict, Optional
import b4
import b4.review
import b4.review.tracking
-
from b4.review_tui._common import logger
+from b4.review_tui._pw_app import PwApp
from b4.review_tui._review_app import ReviewApp
from b4.review_tui._tracking_app import TrackingApp
-from b4.review_tui._pw_app import PwApp
def _tui_use_mouse() -> bool:
diff --git a/src/b4/review_tui/_lite_app.py b/src/b4/review_tui/_lite_app.py
index 7474927..7a37e0d 100644
--- a/src/b4/review_tui/_lite_app.py
+++ b/src/b4/review_tui/_lite_app.py
@@ -6,14 +6,10 @@
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import email.utils
-
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
-import b4
-import b4.review
-import b4.review.tracking
-
+from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
@@ -21,11 +17,16 @@ from textual.screen import ModalScreen
from textual.widgets import Label, ListItem, ListView, LoadingIndicator, RichLog, Static
from textual.worker import Worker, WorkerState
-from rich.text import Text
-
+import b4
+import b4.review
+import b4.review.tracking
from b4.review_tui._common import (
- resolve_styles, _quiet_worker, _fix_ansi_theme,
- _write_diff_line, display_width, pad_display,
+ _fix_ansi_theme,
+ _quiet_worker,
+ _write_diff_line,
+ display_width,
+ pad_display,
+ resolve_styles,
)
from b4.review_tui._modals import FollowupReplyPreviewScreen
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 89e946e..10bffb5 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -10,27 +10,41 @@ import email.utils
import io
import json
import re
-
from typing import Any, Dict, List, Optional, Tuple
-import b4
-
+from rich import box
+from rich.panel import Panel
+from rich.rule import Rule
+from rich.text import Text
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
-from textual.widgets import Checkbox, Input, Label, ListItem, ListView, LoadingIndicator, ProgressBar, RichLog, Select, Static
from textual.screen import ModalScreen
from textual.suggester import SuggestFromList
+from textual.widgets import (
+ Checkbox,
+ Input,
+ Label,
+ ListItem,
+ ListView,
+ LoadingIndicator,
+ ProgressBar,
+ RichLog,
+ Select,
+ Static,
+)
from textual.worker import Worker, WorkerState
-from rich import box
-from rich.panel import Panel
-from rich.rule import Rule
-from rich.text import Text
+import b4
from b4.review_tui._common import (
- CI_CHECK_LABELS, resolve_styles, ci_check_styles,
- JKListNavMixin, logger,
- _write_diff_line, _quiet_worker, _render_email_to_viewer,
+ CI_CHECK_LABELS,
+ JKListNavMixin,
+ _quiet_worker,
+ _render_email_to_viewer,
+ _write_diff_line,
+ ci_check_styles,
+ logger,
+ resolve_styles,
)
@@ -541,11 +555,11 @@ class FollowupReplyPreviewScreen(ModalScreen[Optional[str]]):
# Re-exported from b4.tui (canonical home for shared modals)
-from b4.tui._modals import ToCcScreen as ToCcScreen
-from b4.tui._modals import ConfirmScreen as ConfirmScreen
-from b4.tui._modals import LimitScreen as LimitScreen
from b4.tui._modals import ActionItem as ActionItem
from b4.tui._modals import ActionScreen as ActionScreen
+from b4.tui._modals import ConfirmScreen as ConfirmScreen
+from b4.tui._modals import LimitScreen as LimitScreen
+from b4.tui._modals import ToCcScreen as ToCcScreen
class SendScreen(ModalScreen[bool]):
diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py
index cfc0b11..2b0c10a 100644
--- a/src/b4/review_tui/_pw_app.py
+++ b/src/b4/review_tui/_pw_app.py
@@ -7,24 +7,32 @@ __author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import json
import pathlib
-
from typing import Any, Dict, List, Optional, Set, Tuple
-import b4
-import b4.review
-import b4.review.tracking
-
+from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, Label, ListItem, ListView, LoadingIndicator, Static
from textual.worker import Worker, WorkerState
-from rich.text import Text
-
-from b4.review_tui._common import resolve_styles, ci_styles, logger, SeparatedFooter, _fix_ansi_theme, pad_display
+import b4
+import b4.review
+import b4.review.tracking
+from b4.review_tui._common import (
+ SeparatedFooter,
+ _fix_ansi_theme,
+ ci_styles,
+ logger,
+ pad_display,
+ resolve_styles,
+)
from b4.review_tui._modals import (
- CIChecksScreen, SetStateScreen, ApplyStateModal,
- LimitScreen, HelpScreen, PW_HELP_LINES,
+ PW_HELP_LINES,
+ ApplyStateModal,
+ CIChecksScreen,
+ HelpScreen,
+ LimitScreen,
+ SetStateScreen,
)
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index 3ae3c1a..c6c926d 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -10,39 +10,54 @@ import email.utils
import os
import re
import subprocess
-
from typing import Any, Dict, List, Optional, Set, Tuple
-import b4
-import b4.mbox
-import b4.review
-import b4.review.tracking
-
+from rich.rule import Rule
+from rich.syntax import Syntax
+from rich.text import Text
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.events import Click
from textual.widgets import Label, ListItem, ListView, RichLog, Static
-from rich.rule import Rule
-from rich.syntax import Syntax
-from rich.text import Text
+import b4
+import b4.mbox
+import b4.review
+import b4.review.tracking
from b4.review_tui._common import (
- logger, PATCH_STATE_MARKERS,
- resolve_styles, reviewer_colours, CheckRunnerMixin,
- _quiet_worker, get_thread_msgs,
- _has_review_data, _make_initials, _wait_for_enter,
- _write_comments, _write_followup_comments,
- _write_followup_trailers, _resolve_patch_for_followup,
- _get_followup_depth, _render_email_to_viewer,
- _suspend_to_shell, SeparatedFooter, _fix_ansi_theme,
+ PATCH_STATE_MARKERS,
+ CheckRunnerMixin,
+ SeparatedFooter,
+ _fix_ansi_theme,
+ _get_followup_depth,
+ _has_review_data,
+ _make_initials,
+ _quiet_worker,
+ _render_email_to_viewer,
+ _resolve_patch_for_followup,
+ _suspend_to_shell,
+ _wait_for_enter,
+ _write_comments,
+ _write_followup_comments,
+ _write_followup_trailers,
+ get_thread_msgs,
+ logger,
+ resolve_styles,
+ reviewer_colours,
)
from b4.review_tui._modals import (
- TrailerScreen, HelpScreen, _review_help_lines,
- NoteScreen, PriorReviewScreen, ToCcScreen, SendScreen,
FollowupReplyPreviewScreen,
+ HelpScreen,
+ NoteScreen,
+ PriorReviewScreen,
+ SendScreen,
+ ToCcScreen,
+ TrailerScreen,
+ _review_help_lines,
)
+
class PatchListItem(ListItem):
"""A single entry in the patch list."""
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 1499aa8..99de823 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -17,15 +17,9 @@ import os
import pathlib
import re
import sqlite3
-
from string import Template
from typing import Any, Dict, List, Literal, Optional, Tuple
-import b4
-import b4.mbox
-import b4.review
-import b4.review.tracking
-
from rich.text import Text as RichText
from textual.app import App, ComposeResult
from textual.binding import Binding
@@ -33,19 +27,46 @@ from textual.containers import Horizontal, Vertical
from textual.css.query import NoMatches
from textual.widgets import Footer, Label, ListItem, ListView, Static
from textual.worker import Worker, WorkerState
+
+import b4
+import b4.mbox
+import b4.review
+import b4.review.tracking
from b4.review_tui._common import (
- logger, resolve_styles, _wait_for_enter, _suspend_to_shell,
- SeparatedFooter, _quiet_worker, CheckRunnerMixin,
- _fix_ansi_theme, display_width, pad_display,
+ CheckRunnerMixin,
+ SeparatedFooter,
+ _fix_ansi_theme,
+ _quiet_worker,
+ _suspend_to_shell,
+ _wait_for_enter,
+ display_width,
+ logger,
+ pad_display,
+ resolve_styles,
)
from b4.review_tui._modals import (
- BaseSelectionScreen, WorkerScreen, TakeScreen, TakeConfirmScreen,
- CherryPickScreen, NewerRevisionWarningScreen,
- RevisionChoiceScreen, RebaseScreen, TargetBranchScreen,
+ TRACKING_HELP_LINES,
AbandonConfirmScreen,
- ArchiveConfirmScreen, RangeDiffScreen, ThankScreen, QueueScreen, QueueDeliveryScreen,
- LimitScreen, UpdateRevisionScreen, UpdateAllScreen,
- ActionScreen, HelpScreen, SnoozeScreen, TRACKING_HELP_LINES,
+ ActionScreen,
+ ArchiveConfirmScreen,
+ BaseSelectionScreen,
+ CherryPickScreen,
+ HelpScreen,
+ LimitScreen,
+ NewerRevisionWarningScreen,
+ QueueDeliveryScreen,
+ QueueScreen,
+ RangeDiffScreen,
+ RebaseScreen,
+ RevisionChoiceScreen,
+ SnoozeScreen,
+ TakeConfirmScreen,
+ TakeScreen,
+ TargetBranchScreen,
+ ThankScreen,
+ UpdateAllScreen,
+ UpdateRevisionScreen,
+ WorkerScreen,
)
# Shortcut keys for the tracking-app action selector.
@@ -3708,6 +3729,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
"""
import tarfile
import time
+
import b4.ez
topdir = b4.git_get_toplevel()
@@ -3806,6 +3828,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
def action_thank(self) -> None:
"""Compose and preview a thank-you reply for a taken series."""
import argparse
+
import b4.review
import b4.ty
diff --git a/src/b4/tui/_common.py b/src/b4/tui/_common.py
index f03976d..8eb6c45 100644
--- a/src/b4/tui/_common.py
+++ b/src/b4/tui/_common.py
@@ -11,18 +11,16 @@ import os
import subprocess
import tempfile
import unicodedata
-
-from typing import Any, Dict, List, Optional
-
-import b4
-
from collections import defaultdict
+from typing import Any, Dict, List, Optional
from textual.app import ComposeResult
from textual.binding import Binding
from textual.widgets import Footer, ListView
from textual.widgets._footer import FooterKey
+import b4
+
logger = b4.logger
diff --git a/src/b4/tui/_modals.py b/src/b4/tui/_modals.py
index 46025f4..15f2e3b 100644
--- a/src/b4/tui/_modals.py
+++ b/src/b4/tui/_modals.py
@@ -6,7 +6,7 @@
"""Shared modal screens for b4 Textual apps."""
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
+from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
if TYPE_CHECKING:
from textual.events import Key
@@ -14,10 +14,15 @@ if TYPE_CHECKING:
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical
-from textual.widgets import Checkbox, Input, Label, ListItem, ListView, Static, TextArea
from textual.screen import ModalScreen
+from textual.widgets import Checkbox, Input, Label, ListItem, ListView, Static, TextArea
-from b4.tui._common import JKListNavMixin, _addrs_to_lines, _lines_to_header, _validate_addrs
+from b4.tui._common import (
+ JKListNavMixin,
+ _addrs_to_lines,
+ _lines_to_header,
+ _validate_addrs,
+)
class ToCcScreen(ModalScreen[bool]):
diff --git a/src/b4/ty.py b/src/b4/ty.py
index b429566..5786222 100644
--- a/src/b4/ty.py
+++ b/src/b4/ty.py
@@ -5,23 +5,20 @@
#
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
-import os
-import sys
-
-import b4
-import re
+import argparse
import email
import email.parser
import email.utils
import json
-import argparse
-
-from string import Template
-from pathlib import Path
-
+import os
+import re
+import sys
from email.message import EmailMessage
+from pathlib import Path
+from string import Template
+from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union, cast
-from typing import Callable, cast, Optional, Set, Tuple, Union, List, Dict, Any
+import b4
ConfigDictT = b4.ConfigDictT
JsonDictT = Dict[str, Union[str, int, List[Any], Dict[str, Any]]]
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index f42ade4..d8cd853 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -1,11 +1,12 @@
-import pytest
-import b4
import os
import pathlib
import sys
-
from typing import Generator
+import pytest
+
+import b4
+
@pytest.fixture(scope="function", autouse=True)
def settestdefaults(tmp_path: pathlib.Path) -> None:
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index 755938a..cb01795 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -1,14 +1,15 @@
-import pytest
-import b4
-import os
import email
import email.parser
import io
+import os
import pathlib
import socket
-
from typing import Any, Dict, List, Literal, Optional, Tuple
+import pytest
+
+import b4
+
@pytest.mark.parametrize('source,expected', [
('good-valid-trusted', (True, True, True, 'B6C41CE35664996C', '1623274836')),
diff --git a/src/tests/test_ez.py b/src/tests/test_ez.py
index 7e67a0b..ef21985 100644
--- a/src/tests/test_ez.py
+++ b/src/tests/test_ez.py
@@ -1,12 +1,13 @@
-import pytest
import os
+from typing import Any, Dict, Generator, List, Optional, Tuple
+from unittest.mock import MagicMock, patch
+
+import pytest
+
import b4
+import b4.command
import b4.ez
import b4.mbox
-import b4.command
-
-from typing import Any, Dict, Generator, List, Optional, Tuple
-from unittest.mock import MagicMock, patch
@pytest.fixture(scope="function")
diff --git a/src/tests/test_mbox.py b/src/tests/test_mbox.py
index b3c0536..b533421 100644
--- a/src/tests/test_mbox.py
+++ b/src/tests/test_mbox.py
@@ -1,13 +1,14 @@
-import pytest
import os
-import b4
-import b4.mbox
-import b4.command
-
from email.message import EmailMessage
from typing import Any, Dict, List
from unittest.mock import patch as mock_patch
+import pytest
+
+import b4
+import b4.command
+import b4.mbox
+
@pytest.mark.parametrize('mboxf, shazamargs, compareargs, compareout, b4cfg', [
('shazam-git1-just-series', [],
diff --git a/src/tests/test_patatt.py b/src/tests/test_patatt.py
index 592d546..c257d41 100644
--- a/src/tests/test_patatt.py
+++ b/src/tests/test_patatt.py
@@ -10,12 +10,11 @@ from collections.abc import Generator
from typing import Tuple, Union
import pytest
+from nacl.signing import SigningKey
import b4
import patatt
-from nacl.signing import SigningKey
-
@pytest.fixture()
def ed25519_keypair() -> Generator[Tuple[str, str, str, str], None, None]:
diff --git a/src/tests/test_rethread.py b/src/tests/test_rethread.py
index 1cf5239..f2a0394 100644
--- a/src/tests/test_rethread.py
+++ b/src/tests/test_rethread.py
@@ -1,10 +1,10 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2020 by the Linux Foundation
-import b4
import email.message
+from typing import List, Optional, Tuple
from unittest import mock
-from typing import List, Optional, Tuple
+import b4
# ---------------------------------------------------------------------------
diff --git a/src/tests/test_review.py b/src/tests/test_review.py
index d0853cc..a0513a3 100644
--- a/src/tests/test_review.py
+++ b/src/tests/test_review.py
@@ -6,11 +6,9 @@ from unittest import mock
import pytest
import b4
-from b4 import review
-from b4 import review_tui
+from b4 import review, review_tui
from b4.review._review import REVIEW_MAGIC_MARKER, check_series_attestation
-
# -- Helper diffs used across tests ------------------------------------------
# A minimal single-file, single-hunk diff
diff --git a/src/tests/test_review_checks.py b/src/tests/test_review_checks.py
index 7d737ad..c866082 100644
--- a/src/tests/test_review_checks.py
+++ b/src/tests/test_review_checks.py
@@ -9,7 +9,6 @@ import pytest
from b4.review import checks
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
diff --git a/src/tests/test_review_show_info.py b/src/tests/test_review_show_info.py
index abfbf3c..db955c4 100644
--- a/src/tests/test_review_show_info.py
+++ b/src/tests/test_review_show_info.py
@@ -5,18 +5,17 @@
#
"""Tests for ``b4 review show-info``."""
import json
-import pytest
+import pytest
import b4
import b4.review
from b4.review._review import (
get_review_info,
- show_review_info,
list_review_branches,
+ show_review_info,
)
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 9eb2e98..181b5e5 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -12,8 +12,8 @@ import pytest
import b4
import b4.review
from b4.review import tracking as review_tracking
-from b4.review_tui._tracking_app import _format_snooze_until, _format_attestation
from b4.review_tui._modals import SnoozeScreen
+from b4.review_tui._tracking_app import _format_attestation, _format_snooze_until
class TestGetReviewDataDir:
diff --git a/src/tests/test_three_way_merge.py b/src/tests/test_three_way_merge.py
index 83a4c77..c0127bf 100644
--- a/src/tests/test_three_way_merge.py
+++ b/src/tests/test_three_way_merge.py
@@ -1,13 +1,14 @@
import argparse
import json
import os
+from typing import Any, Dict, Optional, Tuple
+from unittest.mock import patch
+
import pytest
+
import b4
import b4.mbox
-from typing import Any, Dict, Optional, Tuple
-from unittest.mock import patch
-
class TestAmConflictError:
"""Tests for the AmConflictError exception class."""
diff --git a/src/tests/test_tui_bugs.py b/src/tests/test_tui_bugs.py
index 19f073c..db900fd 100644
--- a/src/tests/test_tui_bugs.py
+++ b/src/tests/test_tui_bugs.py
@@ -30,7 +30,6 @@ from b4.bugs._tui import (
label_color,
)
-
# ---------------------------------------------------------------------------
# Helpers -- factory functions for real Bug and BugSummary objects
# ---------------------------------------------------------------------------
diff --git a/src/tests/test_tui_modals.py b/src/tests/test_tui_modals.py
index 7b123cc..e0e6f3f 100644
--- a/src/tests/test_tui_modals.py
+++ b/src/tests/test_tui_modals.py
@@ -9,14 +9,14 @@ Uses Textual's built-in ``App.run_test()`` / ``Pilot`` harness so the
tests run without a real terminal. Only lightweight, self-contained
modals are exercised here — no database, network, or git needed.
"""
-import pytest
-
from typing import Any, Dict, List, Optional, Tuple
+import pytest
from textual.app import App, ComposeResult
from textual.widgets import Input, Label, ListView
from b4.review_tui._modals import (
+ TRACKING_HELP_LINES,
ActionScreen,
ConfirmScreen,
HelpScreen,
@@ -28,10 +28,8 @@ from b4.review_tui._modals import (
SnoozeScreen,
TrailerScreen,
UpdateRevisionScreen,
- TRACKING_HELP_LINES,
)
-
# ---------------------------------------------------------------------------
# Compat helper — Textual ≥ 1.0 (pip) uses Static.content,
# older builds (e.g. Fedora 43 package) still use Static.renderable.
diff --git a/src/tests/test_tui_review.py b/src/tests/test_tui_review.py
index c3a2db4..3222989 100644
--- a/src/tests/test_tui_review.py
+++ b/src/tests/test_tui_review.py
@@ -8,16 +8,14 @@
Tests the shell-return reconciliation logic that detects and handles
cosmetic commit edits (e.g. reworded subjects via git rebase -i).
"""
-import pytest
-
from typing import Any, Dict, List, Tuple
+import pytest
+
import b4
import b4.review
-
from b4.review_tui._review_app import ReviewApp
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 96b160a..80004e8 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -11,28 +11,25 @@ core user workflows: series listing, navigation, filtering,
status transitions, and modal interactions.
"""
import pathlib
-import pytest
-
from typing import Any, Dict, List, Optional
from unittest.mock import patch
+import pytest
+from textual.widgets import Input, ListView, Static
+
import b4
import b4.review
import b4.review.tracking as tracking
-
-from textual.widgets import Input, ListView, Static
-
-from b4.review_tui._tracking_app import TrackingApp, TrackedSeriesItem
from b4.review_tui._modals import (
- ActionScreen,
ActionItem,
+ ActionScreen,
ConfirmScreen,
HelpScreen,
LimitScreen,
SnoozeScreen,
TargetBranchScreen,
)
-
+from b4.review_tui._tracking_app import TrackedSeriesItem, TrackingApp
# ---------------------------------------------------------------------------
# Compat helper — Textual ≥ 1.0 (pip) uses Static.content,
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 04/12] Import dependencies unconditionally
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (2 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 03/12] Use ruff to sort imports Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 05/12] Fix tests under uv with complex git config Tamir Duberstein
` (8 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
These are always available since commit f4185d6b.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
src/b4/__init__.py | 31 +++----------------------------
src/b4/ez.py | 30 ++++--------------------------
src/tests/conftest.py | 1 -
3 files changed, 7 insertions(+), 55 deletions(-)
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index 7cc3a93..a14d89c 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -48,8 +48,11 @@ from typing import (
overload,
)
+import dkim # type: ignore[import-untyped]
import requests
+import patatt
+
ConfigDictT = Dict[str, Union[str, List[str], None]]
from email import charset
@@ -63,20 +66,6 @@ emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length
# adapted from email._parseaddr
qspecials = re.compile(r'[()<>@,:;.\"\[\]]')
-try:
- import dkim # type: ignore[import-untyped]
-
- can_dkim = True
-except ModuleNotFoundError:
- can_dkim = False
-
-try:
- import patatt
-
- can_patatt = True
-except ModuleNotFoundError:
- can_patatt = False
-
# global setting allowing us to turn off networking
can_network = True
@@ -1007,13 +996,6 @@ class LoreSeries:
for trailer in attref:
logger.info(' %s', trailer)
- if not (can_dkim and can_patatt):
- logger.info(' ---')
- if not can_dkim:
- logger.info(' NOTE: install dkimpy for DKIM signature verification')
- if not can_patatt:
- logger.info(' NOTE: install patatt for end-to-end signature verification')
-
return msgs
@@ -1689,9 +1671,6 @@ class LoreMessage:
if not can_network:
logger.debug('Message has DKIM signatures, but can_network is off')
return
- if not can_dkim:
- logger.debug('Message has DKIM signatures, but can_dkim is off')
- return
# Identify all DKIM-Signature headers and try them in reverse order
# until we come to a passing one
@@ -1778,10 +1757,6 @@ class LoreMessage:
self.body = '\n'.join(ibh) + '\n\n' + self.body
def _load_patatt_attestors(self) -> None:
- if not can_patatt:
- logger.debug('Message has %s headers, but can_patatt is off', DEVSIG_HDR)
- return
-
# This should be always the case, but assert it anyway
assert isinstance(self._attestors, list)
diff --git a/src/b4/ez.py b/src/b4/ez.py
index e69a106..562f2a9 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -13,6 +13,7 @@ import email.policy
import email.utils
import gzip
import hashlib
+import importlib.util
import io
import json
import os
@@ -29,21 +30,10 @@ from email.message import EmailMessage
from string import Template
from typing import Any, Dict, List, Optional, Set, Tuple, Union
-import b4
-
-try:
- import patatt
- can_patatt = True
-except ModuleNotFoundError:
- can_patatt = False
+import git_filter_repo as fr # type: ignore[import-untyped]
-try:
- import git_filter_repo as fr # type: ignore[import-untyped]
- can_gfr = True
-except ModuleNotFoundError:
- can_gfr = False
-
-import importlib.util
+import b4
+import patatt
can_codespell = importlib.util.find_spec('codespell_lib') is not None
@@ -150,9 +140,6 @@ def run_frf(frf: fr.RepoFilter) -> None:
but is completely unnecessary for b4's purposes. Delete this file after
each invocation, so it doesn't interfere with subsequent runs.
"""
- if not can_gfr:
- logger.critical('CRITICAL: git-filter-repo is not available')
- sys.exit(1)
run_rewrite_hook('pre')
logger.debug('Running git-filter-repo...')
frf.run()
@@ -2492,13 +2479,6 @@ def reroll(mybranch: str, tag_msg: str, msgid: str, tagprefix: str = SENT_TAG_PR
store_cover(new_cover, tracking)
-def check_can_gfr() -> None:
- if not can_gfr:
- logger.critical('ERROR: b4 submit requires git-filter-repo. You should be able')
- logger.critical(' to install it from your distro packages, or from pip.')
- sys.exit(1)
-
-
def show_revision() -> None:
is_prep_branch(mustbe=True)
_cover, tracking = load_cover()
@@ -3054,7 +3034,6 @@ def set_presubject(presubject: str) -> None:
def cmd_prep(cmdargs: argparse.Namespace) -> None:
- check_can_gfr()
status = b4.git_get_repo_status()
if len(status):
logger.critical('CRITICAL: Repository contains uncommitted changes.')
@@ -3156,7 +3135,6 @@ def cmd_prep(cmdargs: argparse.Namespace) -> None:
def cmd_trailers(cmdargs: argparse.Namespace) -> None:
- check_can_gfr()
status = b4.git_get_repo_status()
if len(status):
logger.critical('CRITICAL: Repository contains uncommitted changes.')
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index d8cd853..3ff3891 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -13,7 +13,6 @@ def settestdefaults(tmp_path: pathlib.Path) -> None:
topdir = b4.git_get_toplevel()
if topdir and topdir != os.getcwd():
os.chdir(topdir)
- b4.can_patatt = False
b4.can_network = False
b4.MAIN_CONFIG = dict(b4.DEFAULT_CONFIG)
b4.USER_CONFIG = {
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 05/12] Fix tests under uv with complex git config
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (3 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 04/12] Import dependencies unconditionally Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 06/12] Fix typings in misc/ Tamir Duberstein
` (7 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Add pytest-asyncio to the dev group so pytest can run async TUI
tests.
Pin git-filter-repo to unreleased commit 4697eeb for the multiline
git config parser fix requested in
https://github.com/newren/git-filter-repo/issues/638.
That parser fix is the only functional change since v2.47.0:
https://github.com/newren/git-filter-repo/compare/v2.47.0...4697eeb37b7c3c30b0492e344f6b89f7139cef26
Inject commit.gpgsign=false through the test fixture so synthetic git
commits do not hang on local GPG/pinentry configuration. Also disable
attestation through MAIN_CONFIG so tests keep the old can_patatt=false
behavior after patatt becomes an unconditional dependency.
As a drive-by, route the test b4 globals, pytest sentinel, and XDG
env overrides through monkeypatch so each test gets automatic
cleanup.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
pyproject.toml | 4 +++-
src/tests/conftest.py | 39 +++++++++++++++++++++++++++++----------
2 files changed, 32 insertions(+), 11 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 4fad7da..8428c5b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,7 +24,9 @@ classifiers = [
]
dependencies = [
"requests>=2.24,<3.0",
- "git-filter-repo>=2.30,<3.0",
+ # Use unreleased fix for multiline git config values.
+ # https://github.com/newren/git-filter-repo/issues/638
+ "git-filter-repo @ git+https://github.com/newren/git-filter-repo.git@4697eeb37b7c3c30b0492e344f6b89f7139cef26",
"dkimpy>=1.0,<2.0",
"patatt>=0.6,<2.0",
"ezgb>=0.1",
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index 3ff3891..83b467d 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -1,3 +1,4 @@
+import copy
import os
import pathlib
import sys
@@ -9,20 +10,38 @@ import b4
@pytest.fixture(scope="function", autouse=True)
-def settestdefaults(tmp_path: pathlib.Path) -> None:
+def settestdefaults(
+ monkeypatch: pytest.MonkeyPatch,
+ tmp_path: pathlib.Path,
+) -> None:
topdir = b4.git_get_toplevel()
if topdir and topdir != os.getcwd():
os.chdir(topdir)
- b4.can_network = False
- b4.MAIN_CONFIG = dict(b4.DEFAULT_CONFIG)
- b4.USER_CONFIG = {
- 'name': 'Test Override',
- 'email': 'test-override@example.com',
- }
- os.environ['XDG_DATA_HOME'] = str(tmp_path)
- os.environ['XDG_CACHE_HOME'] = str(tmp_path)
+ monkeypatch.setattr(b4, 'can_network', False)
+ monkeypatch.setattr(
+ b4,
+ 'MAIN_CONFIG',
+ {
+ **copy.deepcopy(b4.DEFAULT_CONFIG),
+ 'attestation-policy': 'off',
+ },
+ )
+ monkeypatch.setattr(
+ b4,
+ 'USER_CONFIG',
+ {
+ 'name': 'Test Override',
+ 'email': 'test-override@example.com',
+ },
+ )
+ monkeypatch.setenv('XDG_DATA_HOME', str(tmp_path))
+ monkeypatch.setenv('XDG_CACHE_HOME', str(tmp_path))
+ git_config_count = int(os.environ.get('GIT_CONFIG_COUNT', '0'))
+ monkeypatch.setenv('GIT_CONFIG_COUNT', str(git_config_count + 1))
+ monkeypatch.setenv(f'GIT_CONFIG_KEY_{git_config_count}', 'commit.gpgsign')
+ monkeypatch.setenv(f'GIT_CONFIG_VALUE_{git_config_count}', 'false')
# This lets us avoid execvp-ing from inside b4 when testing
- sys._running_in_pytest = True # type: ignore[attr-defined]
+ monkeypatch.setattr(sys, '_running_in_pytest', True, raising=False)
@pytest.fixture(scope="function")
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 06/12] Fix typings in misc/
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (4 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 05/12] Fix tests under uv with complex git config Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 07/12] Enable mypy unreachable warnings Tamir Duberstein
` (6 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
This allows mypy to run over the whole repo.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/retrieve_lore_thread.py | 15 ++-
misc/send-receive.py | 214 +++++++++++++++++++++++++++++--------------
pyproject.toml | 7 ++
3 files changed, 162 insertions(+), 74 deletions(-)
diff --git a/misc/retrieve_lore_thread.py b/misc/retrieve_lore_thread.py
index 4de39fb..3e9a54c 100644
--- a/misc/retrieve_lore_thread.py
+++ b/misc/retrieve_lore_thread.py
@@ -1,7 +1,14 @@
import sys
+from typing import TYPE_CHECKING
-from instructor import OpenAISchema
-from pydantic import Field
+from pydantic import BaseModel, Field
+
+# TODO(https://github.com/567-labs/instructor/pull/2246): remove this once the
+# PR is merged and released.
+if TYPE_CHECKING:
+ OpenAISchema = BaseModel
+else:
+ from instructor import OpenAISchema
# This is needed for now while the minimization bits aren't released
sys.path.insert(0, '/home/user/work/git/korg/b4/src')
@@ -16,8 +23,8 @@ class Function(OpenAISchema):
message_id: str = Field(
...,
- example='20240228-foo-bar-baz@localhost',
- descriptions='Message-ID of the thread to retrieve from lore.kernel.org',
+ examples=['20240228-foo-bar-baz@localhost'],
+ description='Message-ID of the thread to retrieve from lore.kernel.org',
)
class Config:
diff --git a/misc/send-receive.py b/misc/send-receive.py
index 35c5e99..f9f65af 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -16,11 +16,13 @@ import textwrap
from configparser import ConfigParser, ExtendedInterpolation
from email import charset, utils
from string import Template
-from typing import List, Tuple, Union
+from typing import List, Mapping, Optional, Sequence, Tuple, Union
import ezpi
import falcon
import sqlalchemy as sa
+from sqlalchemy.engine import Connection, Engine
+from sqlalchemy.sql import tuple_
import patatt
@@ -34,10 +36,11 @@ DB_VERSION = 1
logger = logging.getLogger('b4-send-receive')
logger.setLevel(logging.DEBUG)
+JSON = Union[str, int, float, bool, Sequence['JSON'], Mapping[str, 'JSON']]
class SendReceiveListener(object):
- def __init__(self, _engine, _config) -> None:
+ def __init__(self, _engine: Engine, _config: ConfigParser) -> None:
self._engine = _engine
self._config = _config
# You shouldn't use this in production
@@ -83,25 +86,25 @@ class SendReceiveListener(object):
conn.execute(q)
conn.close()
- def on_get(self, req, resp):
+ def on_get(self, req: falcon.Request, resp: falcon.Response) -> None:
resp.status = falcon.HTTP_200
resp.content_type = falcon.MEDIA_TEXT
resp.text = "We don't serve GETs here\n"
- def send_error(self, resp, message: str) -> None:
+ def send_error(self, resp: falcon.Response, message: str) -> None:
resp.status = falcon.HTTP_500
logger.critical('Returning error: %s', message)
resp.text = json.dumps({'result': 'error', 'message': message})
- def send_success(self, resp, message: str) -> None:
+ def send_success(self, resp: falcon.Response, message: str) -> None:
resp.status = falcon.HTTP_200
logger.debug('Returning success: %s', message)
resp.text = json.dumps({'result': 'success', 'message': message})
- def get_smtp(self) -> Tuple[Union[smtplib.SMTP, smtplib.SMTP_SSL, None], Tuple[str, str]]:
+ def get_smtp(self) -> Tuple[smtplib.SMTP, Tuple[str, str]]:
sconfig = self._config['sendemail']
server = sconfig.get('smtpserver', 'localhost')
- port = sconfig.get('smtpserverport', 0)
+ port = sconfig.getint('smtpserverport', 0)
encryption = sconfig.get('smtpencryption')
logger.debug('Connecting to %s:%s', server, port)
@@ -132,34 +135,54 @@ class SendReceiveListener(object):
# We assume you know what you're doing if you don't need encryption
smtp = smtplib.SMTP(server, port)
- frompair = utils.getaddresses([sconfig.get('from')])[0]
+ afrom = sconfig.get('from')
+ assert afrom is not None
+ frompair = utils.getaddresses([afrom])[0]
return smtp, frompair
- def auth_new(self, jdata, resp) -> None:
+ def auth_new(self, jdata: Mapping[str, JSON], resp: falcon.Response) -> None:
# Is it already authorized?
conn = self._engine.connect()
md = sa.MetaData()
- identity = jdata.get('identity')
- selector = jdata.get('selector')
+ identity: Optional[JSON] = jdata.get('identity')
+ selector: Optional[JSON] = jdata.get('selector')
+ pubkey: Optional[JSON] = jdata.get('pubkey')
+ if (
+ not isinstance(identity, str)
+ or not isinstance(selector, str)
+ or not isinstance(pubkey, str)
+ ):
+ self.send_error(resp, message='Invalid authentication request')
+ return
logger.info('New authentication request for %s/%s', identity, selector)
- pubkey = jdata.get('pubkey')
t_auth = sa.Table('auth', md, autoload=True, autoload_with=self._engine)
- q = sa.select([t_auth.c.auth_id]).where(t_auth.c.identity == identity, t_auth.c.selector == selector,
- t_auth.c.verified == 1)
- rp = conn.execute(q)
+ select_auth = sa.select(t_auth.c.auth_id).where(
+ t_auth.c.identity == identity,
+ t_auth.c.selector == selector,
+ t_auth.c.verified == 1,
+ )
+ rp = conn.execute(select_auth)
if len(rp.fetchall()):
self.send_error(resp, message='i=%s;s=%s is already authorized' % (identity, selector))
return
# delete any existing challenges for this and create a new one
- q = sa.delete(t_auth).where(t_auth.c.identity == identity, t_auth.c.selector == selector,
- t_auth.c.verified == 0)
- conn.execute(q)
+ delete_auth = sa.delete(t_auth).where(
+ t_auth.c.identity == identity,
+ t_auth.c.selector == selector,
+ t_auth.c.verified == 0,
+ )
+ conn.execute(delete_auth)
# create new challenge
import uuid
cstr = str(uuid.uuid4())
- q = sa.insert(t_auth).values(identity=identity, selector=selector, pubkey=pubkey, challenge=cstr,
- verified=0)
- conn.execute(q)
+ insert_auth = sa.insert(t_auth).values(
+ identity=identity,
+ selector=selector,
+ pubkey=pubkey,
+ challenge=cstr,
+ verified=0,
+ )
+ conn.execute(insert_auth)
logger.info('Created new challenge for %s/%s: %s', identity, selector, cstr)
conn.close()
smtp, frompair = self.get_smtp()
@@ -192,50 +215,62 @@ class SendReceiveListener(object):
destaddrs = [identity]
alwaysbcc = self._config['main'].get('alwayscc')
if alwaysbcc:
- destaddrs += [x[1] for x in utils.getaddresses(alwaysbcc)]
+ destaddrs += [x[1] for x in utils.getaddresses([alwaysbcc])]
logger.info('Sending challenge to %s', identity)
smtp.sendmail(fromaddr, [identity], bdata)
smtp.close()
self.send_success(resp, message=f'Challenge generated and sent to {identity}')
- def validate_message(self, conn, t_auth, bdata, verified=1) -> Tuple[str, str, int]:
+ def validate_message(
+ self,
+ conn: Connection,
+ t_auth: sa.Table,
+ bdata: bytes,
+ verified: int = 1,
+ ) -> Tuple[str, str, int]:
# Returns auth_id of the matching record
pm = patatt.PatattMessage(bdata)
if not pm.signed:
raise patatt.ValidationError('Message is not signed')
- auth_id = identity = selector = pubkey = None
- for ds in pm.get_sigs():
- selector = 'default'
- identity = ''
- i = ds.get_field('i')
- if i:
- identity = i.decode()
- s = ds.get_field('s')
- if s:
- selector = s.decode()
- logger.debug('i=%s; s=%s', identity, selector)
- q = sa.select([t_auth.c.auth_id, t_auth.c.pubkey]).where(t_auth.c.identity == identity,
- t_auth.c.selector == selector,
- t_auth.c.verified == verified)
- rp = conn.execute(q)
- res = rp.fetchall()
- if res:
- auth_id, pubkey = res[0]
- break
-
- if not auth_id:
+ identity_selector_pairs = [
+ (
+ ''
+ if (i := ds.get_field('i')) is None
+ else i.decode()
+ if isinstance(i, bytes)
+ else i,
+ 'default'
+ if (s := ds.get_field('s')) is None
+ else s.decode()
+ if isinstance(s, bytes)
+ else s,
+ )
+ for ds in pm.get_sigs()
+ ]
+ logger.debug('is_pairs=%s', identity_selector_pairs)
+ q = sa.select(
+ t_auth.c.identity, t_auth.c.selector, t_auth.c.auth_id, t_auth.c.pubkey
+ ).where(
+ tuple_(t_auth.c.identity, t_auth.c.selector).in_(identity_selector_pairs),
+ t_auth.c.verified == verified,
+ )
+ rp = conn.execute(q)
+ rows = rp.fetchall()
+ if not rows:
logger.debug('Did not find a matching identity!')
raise patatt.NoKeyError('No match for this identity')
+ identity, selector, auth_id, pubkey = rows[0]
+
logger.debug('Found matching %s/%s with auth_id=%s', identity, selector, auth_id)
pm.validate(identity, pubkey.encode())
return identity, selector, auth_id
- def auth_verify(self, jdata, resp) -> None:
+ def auth_verify(self, jdata: Mapping[str, JSON], resp: falcon.Response) -> None:
msg = jdata.get('msg')
- if msg.find('\nverify:') < 0:
+ if not isinstance(msg, str) or msg.find('\nverify:') < 0:
self.send_error(resp, message='Invalid verification message')
return
conn = self._engine.connect()
@@ -250,22 +285,28 @@ class SendReceiveListener(object):
logger.debug('Message validation passed for %s/%s with auth_id=%s', identity, selector, auth_id)
# Now compare the challenge to what we received
- q = sa.select([t_auth.c.challenge]).where(t_auth.c.auth_id == auth_id)
- rp = conn.execute(q)
+ select_challenge = sa.select(t_auth.c.challenge).where(
+ t_auth.c.auth_id == auth_id
+ )
+ rp = conn.execute(select_challenge)
res = rp.fetchall()
challenge = res[0][0]
if msg.find(f'\nverify:{challenge}') < 0:
self.send_error(resp, message='Challenge verification for %s/%s did not match' % (identity, selector))
return
logger.info('Successfully verified challenge for %s/%s with auth_id=%s', identity, selector, auth_id)
- q = sa.update(t_auth).where(t_auth.c.auth_id == auth_id).values(challenge=None, verified=1)
- conn.execute(q)
+ update_auth = (
+ sa.update(t_auth)
+ .where(t_auth.c.auth_id == auth_id)
+ .values(challenge=None, verified=1)
+ )
+ conn.execute(update_auth)
conn.close()
self.send_success(resp, message='Challenge verified for %s/%s' % (identity, selector))
- def auth_delete(self, jdata, resp) -> None:
+ def auth_delete(self, jdata: Mapping[str, JSON], resp: falcon.Response) -> None:
msg = jdata.get('msg')
- if msg.find('\nauth-delete') < 0:
+ if not isinstance(msg, str) or msg.find('\nauth-delete') < 0:
self.send_error(resp, message='Invalid key delete message')
return
conn = self._engine.connect()
@@ -279,12 +320,12 @@ class SendReceiveListener(object):
return
logger.info('Deleting record for %s/%s with auth_id=%s', identity, selector, auth_id)
- q = sa.delete(t_auth).where(t_auth.c.auth_id == auth_id)
- conn.execute(q)
+ delete_auth = sa.delete(t_auth).where(t_auth.c.auth_id == auth_id)
+ conn.execute(delete_auth)
conn.close()
self.send_success(resp, message='Record deleted for %s/%s' % (identity, selector))
- def clean_header(self, hdrval: str) -> str:
+ def clean_header(self, hdrval: Optional[str]) -> str:
if hdrval is None:
return ''
@@ -325,7 +366,13 @@ class SendReceiveListener(object):
except AttributeError:
return all([ord(c) < 128 for c in strval])
- def wrap_header(self, hdr, width: int = 75, nl: str = '\r\n', transform: str = 'preserve') -> bytes:
+ def wrap_header(
+ self,
+ hdr: Tuple[str, str],
+ width: int = 75,
+ nl: str = '\r\n',
+ transform: str = 'preserve',
+ ) -> bytes:
hname, hval = hdr
if hname.lower() in ('to', 'cc', 'from', 'x-original-from'):
_parts = [f'{hname}: ']
@@ -388,11 +435,17 @@ class SendReceiveListener(object):
bdata += self.wrap_header((hname, str(hval)), nl=nl, transform=headers) + nl.encode()
bdata += nl.encode()
payload = msg.get_payload(decode=True)
+ assert isinstance(payload, bytes)
for bline in payload.split(b'\n'):
bdata += re.sub(rb'[\r\n]*$', b'', bline) + nl.encode()
return bdata
- def receive(self, jdata, resp, reflect: bool = False) -> None:
+ def receive(
+ self,
+ jdata: Mapping[str, JSON],
+ resp: falcon.Response,
+ reflect: bool = False,
+ ) -> None:
servicename = self._config['main'].get('myname')
if not servicename:
servicename = 'Web Endpoint'
@@ -400,6 +453,9 @@ class SendReceiveListener(object):
if not umsgs:
self.send_error(resp, message='Missing the messages array')
return
+ if not isinstance(umsgs, Sequence):
+ self.send_error(resp, message='Invalid messages array')
+ return
logger.debug('Received a request for %s messages', len(umsgs))
diffre = re.compile(rb'^(---.*\n\+\+\+|GIT binary patch|diff --git \w/\S+ \w/\S+)', flags=re.M | re.I)
@@ -413,6 +469,9 @@ class SendReceiveListener(object):
# First, validate all messages
seenid = identity = selector = validfrom = None
for umsg in umsgs:
+ if not isinstance(umsg, str):
+ self.send_error(resp, message='Invalid message payload')
+ return
bdata = umsg.encode()
try:
identity, selector, auth_id = self.validate_message(conn, t_auth, bdata)
@@ -446,6 +505,7 @@ class SendReceiveListener(object):
passes = False
if passes:
payload = msg.get_payload(decode=True)
+ assert isinstance(payload, bytes)
if not (diffre.search(payload) or diffstatre.search(payload)):
passes = False
@@ -460,7 +520,9 @@ class SendReceiveListener(object):
# Make sure that From: matches the validated identity. We allow + expansion,
# such that foo+listname@example.com is allowed for foo@example.com
- allfroms = utils.getaddresses([str(x) for x in msg.get_all('from')])
+ froms = msg.get_all('from')
+ assert froms is not None
+ allfroms = utils.getaddresses(froms)
# Allow only a single From: address
if len(allfroms) > 1:
self.send_error(resp, message='Message may only contain a single From: address.')
@@ -497,6 +559,10 @@ class SendReceiveListener(object):
msg.add_header('X-Endpoint-Received', f'by {servicename} for {identity}/{selector} with auth_id={auth_id}')
msgs.append((msg, destaddrs))
+ # Must be the case if the loop above runs at least once, and we check
+ # that umsgs is truthy (not empty).
+ assert identity is not None
+
conn.close()
# All signatures verified. Prepare messages for sending.
cfgdomains = self._config['main'].get('mydomains')
@@ -511,12 +577,12 @@ class SendReceiveListener(object):
if _bcc:
bccaddrs.update([x[1] for x in utils.getaddresses([_bcc])])
- repo = listid = None
- if 'public-inbox' in self._config and self._config['public-inbox'].get('repo') and not reflect:
- repo = self._config['public-inbox'].get('repo')
- listid = self._config['public-inbox'].get('listid')
- if not os.path.isdir(repo):
- repo = None
+ repo_and_listid = None
+ if 'public-inbox' in self._config and not reflect:
+ public_inbox = self._config['public-inbox']
+ if (repo := public_inbox.get('repo')) is not None and os.path.isdir(repo):
+ if (listid := public_inbox.get('listid')) is not None:
+ repo_and_listid = (repo, listid)
if reflect:
logger.info('Reflecting %s messages back to %s', len(msgs), identity)
@@ -527,7 +593,8 @@ class SendReceiveListener(object):
for msg, destaddrs in msgs:
subject = self.clean_header(msg.get('Subject'))
- if repo:
+ if repo_and_listid is not None:
+ repo, listid = repo_and_listid
pmsg = copy.deepcopy(msg)
if pmsg.get('List-Id'):
pmsg.replace_header('List-Id', listid)
@@ -537,7 +604,9 @@ class SendReceiveListener(object):
logger.debug('Wrote %s to public-inbox at %s', subject, repo)
origfrom = msg.get('From')
+ assert origfrom is not None
origpair = utils.getaddresses([origfrom])[0]
+ assert origpair is not None
origaddr = origpair[1]
# Does it match one of our domains
mydomain = False
@@ -573,6 +642,7 @@ class SendReceiveListener(object):
msg.add_header('Reply-To', f'<{origpair[1]}>')
body = msg.get_payload(decode=True)
+ assert isinstance(body, bytes)
# Add a From: header (if there isn't already one), but only if it's a patch
if diffre.search(body):
# Parse it as a message and see if we get a From: header
@@ -606,14 +676,15 @@ class SendReceiveListener(object):
logger.info('---DRYRUN MSG END---')
smtp.close()
- if repo:
+ if repo_and_listid is not None:
+ repo, _ = repo_and_listid
# run it once after writing all messages
logger.debug('Running public-inbox repo hook (if present)')
ezpi.run_hook(repo)
logger.info('%s %s messages for %s/%s', sentaction, len(msgs), identity, selector)
self.send_success(resp, message=f'{sentaction} {len(msgs)} messages for {identity}/{selector}')
- def on_post(self, req, resp):
+ def on_post(self, req: falcon.Request, resp: falcon.Response) -> None:
if not req.content_length:
resp.status = falcon.HTTP_500
resp.content_type = falcon.MEDIA_TEXT
@@ -621,15 +692,15 @@ class SendReceiveListener(object):
return
raw = req.bounded_stream.read()
try:
- jdata = json.loads(raw)
+ jdata: JSON = json.loads(raw)
except Exception:
resp.status = falcon.HTTP_500
resp.content_type = falcon.MEDIA_TEXT
resp.text = 'Failed to parse the request\n'
return
- action = jdata.get('action')
- if not action:
+ if not isinstance(jdata, Mapping) or (action := jdata.get('action')) is None:
logger.critical('Action not set from %s', req.remote_addr)
+ return
logger.info('Action: %s; from: %s', action, req.remote_addr)
if action == 'auth-new':
@@ -666,6 +737,9 @@ if gpgbin:
patatt.GPGBIN = gpgbin
dburl = parser['main'].get('dburl')
+if not dburl:
+ sys.stderr.write('main.dburl is not set in CONFIG')
+ sys.exit(1)
# By default, recycle db connections after 5 min
db_pool_recycle = parser['main'].getint('dbpoolrecycle', 300)
engine = sa.create_engine(dburl, pool_recycle=db_pool_recycle)
diff --git a/pyproject.toml b/pyproject.toml
index 8428c5b..c08f47e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -41,6 +41,13 @@ dev = [
"ruff",
"types-requests",
]
+misc = [
+ "ezpi",
+ "falcon",
+ "instructor",
+ "pydantic",
+ "sqlalchemy",
+]
[project.optional-dependencies]
completion = [
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 07/12] Enable mypy unreachable warnings
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (5 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 06/12] Fix typings in misc/ Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 08/12] Enable and fix pyright diagnostics Tamir Duberstein
` (5 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Turn on warn_unreachable and remove dead branches that it exposes.
Some of those branches were stale null checks against non-optional APIs.
Others were test assertions that hit mypy's stale narrowing of mutable
attributes, so add targeted ignores with a reference to the upstream
issue.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
pyproject.toml | 2 +-
src/b4/__init__.py | 6 ++----
src/b4/mbox.py | 2 --
src/b4/pr.py | 2 +-
src/b4/review/tracking.py | 3 ++-
src/b4/review_tui/_common.py | 4 ----
src/b4/review_tui/_modals.py | 2 --
src/b4/review_tui/_review_app.py | 4 ++--
src/b4/ty.py | 3 ---
src/tests/test_tui_modals.py | 12 +++++++++---
src/tests/test_tui_tracking.py | 12 +++++++++---
11 files changed, 26 insertions(+), 26 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index c08f47e..982ad95 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -121,6 +121,6 @@ flake8-quotes.inline-quotes = "single"
[tool.pyright]
typeCheckingMode = "off"
-# Configure mypy in strict mode
[tool.mypy]
strict = true
+warn_unreachable = true
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index a14d89c..20d1f75 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -1056,9 +1056,7 @@ class LoreSeries:
return len(self.indexes), mismatches
def find_base(self, gitdir: Optional[str], branches: Optional[List[str]] = None, maxdays: int = 30) -> Tuple[str, int, int]:
- if self.indexes is None:
- self.populate_indexes()
- if self.indexes is None or not len(self.indexes):
+ if not self.indexes:
raise IndexError('No indexes to check against')
pdate = self.submission_date
@@ -1554,7 +1552,7 @@ class LoreMessage:
# walk until we find the first text/plain part
self.body, self.charset = LoreMessage.get_payload(self.msg)
- if self.body is None:
+ if not self.body:
# Woah, we didn't find any usable parts
logger.debug(' No plain or patch parts found in message')
logger.info(' Not plaintext: %s', self.full_subject)
diff --git a/src/b4/mbox.py b/src/b4/mbox.py
index 624a2f3..5e8093a 100644
--- a/src/b4/mbox.py
+++ b/src/b4/mbox.py
@@ -202,8 +202,6 @@ def make_am(msgs: List[EmailMessage], cmdargs: argparse.Namespace, msgid: str) -
if cmdargs.cherrypick == '_':
# We might want to pick a patch sent as a followup, so create a fake series
# and add followups with diffs
- if lser is None:
- lser = b4.LoreSeries(revision=1, expected=1)
for followup in lmbx.followups:
if followup.has_diff:
lser.add_patch(followup)
diff --git a/src/b4/pr.py b/src/b4/pr.py
index cb2ca76..939c1f5 100644
--- a/src/b4/pr.py
+++ b/src/b4/pr.py
@@ -84,7 +84,7 @@ def git_get_commit_id_from_repo_ref(repo: str, ref: str) -> Optional[str]:
def parse_pr_data(msg: email.message.EmailMessage) -> Optional[b4.LoreMessage]:
lmsg = b4.LoreMessage(msg)
- if lmsg.body is None:
+ if not lmsg.body:
logger.critical('Could not find a plain part in the message body')
return None
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index e66cfd8..2b0d058 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -222,13 +222,14 @@ def record_take_branch(gitdir: str, branch: str) -> None:
metadata_dir = os.path.join(gitdir, REVIEW_METADATA_DIR)
pathlib.Path(metadata_dir).mkdir(parents=True, exist_ok=True)
metadata_path = get_repo_metadata_path(gitdir)
- data: Dict[str, Any] = {}
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
pass
+ else:
+ data = {}
if not isinstance(data, dict):
data = {}
branches = data.get('recent-take-branches', [])
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index f30405d..536af2d 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -720,10 +720,6 @@ def gather_attestation_info(lser: b4.LoreSeries) -> Dict[str, Any]:
apply_mismatches = 0
if topdir:
- # Ensure indexes are populated for applicability check
- if lser.indexes is None:
- lser.populate_indexes()
-
if base_commit:
base_exists = b4.git_commit_exists(topdir, base_commit)
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 10bffb5..0cd80e1 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -2156,8 +2156,6 @@ class TargetBranchScreen(ModalScreen[Optional[str]]):
ifh = io.BytesIO()
b4.save_git_am_mbox(am_msgs, ifh)
ambytes = ifh.getvalue()
- if lser.indexes is None:
- lser.populate_indexes()
return lser, ambytes
def _check_applicability(self, branch: str) -> None:
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index c6c926d..c14005b 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -1150,7 +1150,7 @@ class ReviewApp(CheckRunnerMixin, App[None]):
result = b4.edit_in_editor(
editor_text.encode(), filehint='reply.b4-review.eml')
- if result is None:
+ if not result:
self.notify('Editor returned no content')
return
reply_text = result.decode(errors='replace')
@@ -1271,7 +1271,7 @@ class ReviewApp(CheckRunnerMixin, App[None]):
with self.suspend():
result = b4.edit_in_editor(editor_text.encode(), filehint='note.txt')
- if result is None:
+ if not result:
self.notify('Editor returned no content')
return
raw_text = result.decode(errors='replace')
diff --git a/src/b4/ty.py b/src/b4/ty.py
index 5786222..74c094a 100644
--- a/src/b4/ty.py
+++ b/src/b4/ty.py
@@ -460,9 +460,6 @@ def send_messages(listing: List[JsonDictT], branch: str, cmdargs: argparse.Names
# This is a patch series
msg = generate_am_thanks(gitdir, jsondata, branch, cmdargs)
- if msg is None:
- continue
-
assert isinstance(jsondata['msgid'], str), 'msgid must be a string'
msgids.append(jsondata['msgid'])
assert isinstance(jsondata['patches'], list), 'patches must be a list'
diff --git a/src/tests/test_tui_modals.py b/src/tests/test_tui_modals.py
index e0e6f3f..6a608a9 100644
--- a/src/tests/test_tui_modals.py
+++ b/src/tests/test_tui_modals.py
@@ -84,7 +84,9 @@ class TestHelpScreen:
await pilot.pause()
# Should be back on the host screen
assert not isinstance(app.screen, HelpScreen)
- assert dismissed == [None]
+ # https://github.com/python/mypy/issues/9457:
+ # app.screen is stale-narrowed across await.
+ assert dismissed == [None] # type: ignore[unreachable]
@pytest.mark.asyncio
async def test_question_mark_dismisses(self) -> None:
@@ -168,7 +170,9 @@ class TestConfirmScreen:
await pilot.press('y')
await pilot.pause()
assert not isinstance(app.screen, ConfirmScreen)
- assert results == [True]
+ # https://github.com/python/mypy/issues/9457:
+ # app.screen is stale-narrowed across await.
+ assert results == [True] # type: ignore[unreachable]
@pytest.mark.asyncio
async def test_escape_cancels(self) -> None:
@@ -459,7 +463,9 @@ class TestPriorReviewScreen:
await pilot.press('escape')
await pilot.pause()
assert not isinstance(app.screen, PriorReviewScreen)
- assert results == [None]
+ # https://github.com/python/mypy/issues/9457:
+ # app.screen is stale-narrowed across await.
+ assert results == [None] # type: ignore[unreachable]
@pytest.mark.asyncio
async def test_content_rendered(self) -> None:
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 80004e8..103a7c3 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -934,7 +934,9 @@ class TestTrackingSnooze:
assert not isinstance(app.screen, SnoozeScreen)
# Verify DB was updated
- conn = tracking.get_db(identifier)
+ # https://github.com/python/mypy/issues/9457:
+ # app.screen is stale-narrowed across await.
+ conn = tracking.get_db(identifier) # type: ignore[unreachable]
cursor = conn.execute(
'SELECT status, snoozed_until FROM series WHERE change_id = ?',
('snooze-test-1',))
@@ -1930,7 +1932,9 @@ class TestTargetBranch:
assert not isinstance(app.screen, TargetBranchScreen)
# Verify DB cleared
- conn = tracking.get_db(identifier)
+ # https://github.com/python/mypy/issues/9457:
+ # app.screen is stale-narrowed across await.
+ conn = tracking.get_db(identifier) # type: ignore[unreachable]
target = tracking.get_target_branch(conn, change_id)
conn.close()
assert target is None
@@ -2358,7 +2362,9 @@ class TestLoadSeriesCaching:
assert app._cached_branch_tips is not None
app._invalidate_caches()
assert app._cached_branch_tips is None
- assert app._cached_newest_revisions is None
+ # https://github.com/python/mypy/issues/9457:
+ # app._cached_branch_tips is stale-narrowed across a method call.
+ assert app._cached_newest_revisions is None # type: ignore[unreachable]
assert app._cached_revision_counts is None
assert app._cached_art_counts is None
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 08/12] Enable and fix pyright diagnostics
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (6 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 07/12] Enable mypy unreachable warnings Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 09/12] Avoid duplicate map lookups Tamir Duberstein
` (4 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Enable Pyright in standard mode, add it to the dev dependency group, and
report unused imports.
Move lazy b4.review, b4.ty, and b4.ez imports to module scope so Pyright
can see package attributes without local import side effects, and add
explicit stdlib email submodule imports in tests for the same reason.
Tighten type annotations and assertions that Pyright reports on,
including logger and send-mail queue types, Message-ID revision values,
Patchwork state strings, JSON trackfile/msgid fields, and TUI callbacks
that may be dismissed with None.
Fix control-flow edges surfaced by reportPossiblyUnboundVariable:
duplicate list-id preference fallback, patch-id matching after skipped
duplicate diffs, trailer source logging without a backing message,
metadata JSON decoding, and review reply rendering.
Reject non-string -c/--config assignments explicitly and avoid using
ConfigDictT values as dict keys in tests.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/send-receive.py | 2 ++
pyproject.toml | 8 +++++---
src/b4/__init__.py | 30 +++++++++++++++++-------------
src/b4/command.py | 11 ++++++-----
src/b4/dig.py | 4 ++--
src/b4/ez.py | 14 +++++++++-----
src/b4/review/_review.py | 12 ++++++++----
src/b4/review/tracking.py | 5 ++---
src/b4/review_tui/_common.py | 4 ----
src/b4/review_tui/_modals.py | 17 +++++++----------
src/b4/review_tui/_pw_app.py | 3 ++-
src/b4/review_tui/_review_app.py | 26 ++++++++++++++------------
src/b4/review_tui/_tracking_app.py | 31 +++++++++++--------------------
src/b4/ty.py | 13 +++++++++----
src/tests/test___init__.py | 4 +++-
src/tests/test_review_tracking.py | 7 ++++---
16 files changed, 101 insertions(+), 90 deletions(-)
diff --git a/misc/send-receive.py b/misc/send-receive.py
index f9f65af..3f00f09 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -3,8 +3,10 @@
import copy
import email
import email.header
+import email.message
import email.policy
import email.quoprimime
+import email.utils
import json
import logging
import logging.handlers
diff --git a/pyproject.toml b/pyproject.toml
index 982ad95..389e573 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ dependencies = [
dev = [
"mypy",
"pip-tools",
+ "pyright",
"pytest",
"pytest-asyncio",
"ruff",
@@ -118,9 +119,10 @@ select = [
]
flake8-quotes.inline-quotes = "single"
-[tool.pyright]
-typeCheckingMode = "off"
-
[tool.mypy]
strict = true
warn_unreachable = true
+
+[tool.pyright]
+typeCheckingMode = "standard"
+reportUnusedImport = true
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index 20d1f75..f38787b 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -90,7 +90,7 @@ def _dkim_log_filter(record: logging.LogRecord) -> bool:
return True
-logger = logging.getLogger('b4')
+logger: logging.Logger = logging.getLogger('b4')
dkimlogger = logger.getChild('dkim')
dkimlogger.addFilter(_dkim_log_filter)
@@ -1195,21 +1195,21 @@ class LoreSeries:
seenfiles.add(nfn)
# Try to grab full ref_id of this hash
try:
- ohash = git_revparse_obj(ofi)
+ hash = git_revparse_obj(ofi)
logger.debug(' Found matching blob for: %s', ofn)
- gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{ohash},{ofn}']
+ gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{hash},{ofn}']
except RuntimeError:
logger.debug('Could not find matching blob for %s (%s)', ofn, ofi)
try:
- chash = git_revparse_obj(f':{ofn}', topdir)
- gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{chash},{ofn}']
+ hash = git_revparse_obj(f':{ofn}', topdir)
+ gitargs = ['update-index', '--add', '--cacheinfo', f'{fmod},{hash},{ofn}']
except RuntimeError:
logger.critical(' ERROR: Could not find anything matching %s', ofn)
return None, None
ecode, out = git_run_command(None, gitargs)
if ecode > 0:
- logger.critical(' ERROR: Could not run update-index for %s (%s)', ofn, ohash)
+ logger.critical(' ERROR: Could not run update-index for %s (%s)', ofn, hash)
return None, None
msgs.append(lmsg.get_am_message(add_trailers=False))
@@ -2374,17 +2374,21 @@ class LoreMessage:
listid1 = LoreMessage.get_clean_msgid(msg1, 'list-id')
if listid1:
- for prefidx1, listglob in enumerate(listidpref): # noqa: B007
- if fnmatch.fnmatch(listid1, listglob):
- break
+ prefidx1 = next(
+ idx
+ for idx, listglob in enumerate(listidpref)
+ if fnmatch.fnmatch(listid1, listglob)
+ )
else:
prefidx1 = listidpref.index('*')
listid2 = LoreMessage.get_clean_msgid(msg2, 'list-id')
if listid2:
- for prefidx2, listglob in enumerate(listidpref): # noqa: B007
- if fnmatch.fnmatch(listid2, listglob):
- break
+ prefidx2 = next(
+ idx
+ for idx, listglob in enumerate(listidpref)
+ if fnmatch.fnmatch(listid2, listglob)
+ )
else:
prefidx2 = listidpref.index('*')
@@ -4601,7 +4605,7 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs
patatt_sign: bool = False, dryrun: bool = False,
output_dir: Optional[str] = None, web_endpoint: Optional[str] = None,
reflect: bool = False) -> Optional[int]:
- tosend = list()
+ tosend: List[Tuple[Set[str], bytes, LoreSubject]] = list()
if output_dir is not None:
dryrun = True
diff --git a/src/b4/command.py b/src/b4/command.py
index a70a986..fc55c71 100644
--- a/src/b4/command.py
+++ b/src/b4/command.py
@@ -150,12 +150,13 @@ class ConfigOption(argparse.Action):
config = dict()
setattr(namespace, self.dest, config)
- if isinstance(keyval, str):
- if '=' in keyval:
- key, value = keyval.split('=', maxsplit=1)
- else:
- key, value = keyval, 'true'
+ if not isinstance(keyval, str):
+ raise TypeError(f'Expected a string config assignment, got {keyval!r}')
+ if '=' in keyval:
+ key, value = keyval.split('=', maxsplit=1)
+ else:
+ key, value = keyval, 'true'
config[key] = value
diff --git a/src/b4/dig.py b/src/b4/dig.py
index b3d637d..46e2510 100644
--- a/src/b4/dig.py
+++ b/src/b4/dig.py
@@ -276,7 +276,7 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None:
if not best_match:
# Next, try to find by exact patch-id
for lmsg in all_lmsgs:
- if lmsg.git_patch_id == patch_id:
+ if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # broken since 3ae277e9c7dd3e1df61a14884aabdd5834ad1201
logger.debug('matched by exact patch-id')
best_match = lmsg
break
@@ -331,7 +331,7 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None:
continue
if firstmsg is None:
firstmsg = lmsg
- if lmsg.git_patch_id == patch_id:
+ if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # broken since inception in 16329336c1c8faba853b11238a16249306742505
logger.debug('Matched by exact patch-id')
break
if lmsg.subject == csubj:
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 562f2a9..b54e04c 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -1305,7 +1305,7 @@ def update_trailers(cmdargs: argparse.Namespace) -> None:
if fltr.lmsg is not None:
source = midmask % urllib.parse.quote_plus(fltr.lmsg.msgid, safe='@')
logger.info(' + %s', rendered)
- logger.info(' via: %s', source)
+ logger.info(' via: %s', source) # pyright: ignore[reportPossiblyUnboundVariable] # broken since 742e017c1b5b91d0e6fd6fca7decf73956b31487
else:
logger.debug(' . %s', fltr.as_string(omit_extinfo=True))
@@ -1442,7 +1442,7 @@ def get_base_changeid_from_tag(tagname: str) -> Tuple[str, str, str]:
return cover, base_commit, change_id
-def make_msgid_tpt(change_id: str, revision: str, domain: Optional[str] = None) -> str:
+def make_msgid_tpt(change_id: str, revision: int, domain: Optional[str] = None) -> str:
if not domain:
usercfg = b4.get_user_config()
myemail = usercfg.get('email')
@@ -1580,7 +1580,7 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr
if prefixes is None:
prefixes = list()
- prefixes += tracking['series'].get('prefixes', list())
+ prefixes.extend(tracking['series'].get('prefixes', list()))
base_commit, start_commit, end_commit = get_series_range(usebranch=usebranch)
change_id = tracking['series'].get('change-id')
revision = tracking['series'].get('revision')
@@ -1718,6 +1718,10 @@ def get_prep_branch_as_patches(movefrom: bool = True, thread: bool = True, addtr
'prerequisites': prerequisites,
'signature': b4.get_email_signature(),
}
+ # Possible type confusion here; revision is initially a string, but is
+ # then assigned an integer in the loop above. What happens if that loop
+ # runs zero times? Unclear.
+ assert isinstance(revision, int)
if cover_template.find('${range_diff}') >= 0:
if revision > 1:
oldrev = revision - 1
@@ -1781,7 +1785,7 @@ def get_sent_tag_as_patches(tagname: str, revision: int, presubject: Optional[st
csubject, cbody = get_cover_subject_body(cover)
cbody = cbody.strip() + '\n-- \n' + b4.get_email_signature()
prefixes = ['RESEND'] + csubject.get_extra_prefixes(exclude=['RESEND'])
- msgid_tpt = make_msgid_tpt(change_id, str(revision))
+ msgid_tpt = make_msgid_tpt(change_id, revision)
seriests = int(time.time())
mailfrom = b4.get_mailfrom()
@@ -2794,7 +2798,7 @@ def force_revision(forceto: int) -> None:
store_cover(cover, tracking)
-def range_diff_compare(compareto: str, execvp: bool = True, range_diff_opts: Optional[str] = None) -> Union[str, None]:
+def range_diff_compare(compareto: int, execvp: bool = True, range_diff_opts: Optional[str] = None) -> Union[str, None]:
_, tracking = load_cover()
# Try the new format first
tagname, _ = get_sent_tagname(tracking['series']['change-id'], SENT_TAG_PREFIX, compareto)
diff --git a/src/b4/review/_review.py b/src/b4/review/_review.py
index 3d5490b..0e20e81 100644
--- a/src/b4/review/_review.py
+++ b/src/b4/review/_review.py
@@ -19,6 +19,7 @@ from typing import Any, Dict, List, Optional, Set, Tuple, Union
import b4
import b4.mbox
+import b4.review
import b4.review.tracking
logger = b4.logger
@@ -2068,7 +2069,7 @@ def update_series_tracking(
def cmd_tui(cmdargs: argparse.Namespace) -> None:
try:
- import b4.review_tui
+ import b4.review_tui as review_tui
except ImportError:
logger.critical('The TUI requires the textual library.')
logger.critical('Install it with: pip install b4[tui]')
@@ -2093,9 +2094,12 @@ def cmd_tui(cmdargs: argparse.Namespace) -> None:
logger.critical('Enroll with: b4 review enroll')
sys.exit(1)
- b4.review_tui.run_tracking_tui(identifier, email_dryrun=cmdargs.email_dryrun,
- no_sign=cmdargs.no_sign,
- no_mouse=cmdargs.no_mouse)
+ review_tui.run_tracking_tui(
+ identifier,
+ email_dryrun=cmdargs.email_dryrun,
+ no_sign=cmdargs.no_sign,
+ no_mouse=cmdargs.no_mouse,
+ )
def _prepare_review_session(cmdargs: argparse.Namespace) -> Dict[str, Any]:
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 2b0d058..9c5d33f 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -222,14 +222,13 @@ def record_take_branch(gitdir: str, branch: str) -> None:
metadata_dir = os.path.join(gitdir, REVIEW_METADATA_DIR)
pathlib.Path(metadata_dir).mkdir(parents=True, exist_ok=True)
metadata_path = get_repo_metadata_path(gitdir)
+ data: object = {}
if os.path.exists(metadata_path):
try:
with open(metadata_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, OSError):
pass
- else:
- data = {}
if not isinstance(data, dict):
data = {}
branches = data.get('recent-take-branches', [])
@@ -726,7 +725,7 @@ def get_all_revisions_grouped(conn: sqlite3.Connection) -> dict[str, list[dict[s
'FROM revisions ORDER BY change_id, revision ASC')
result: dict[str, list[dict[str, Any]]] = {}
for row in cursor.fetchall():
- entry = dict(zip(cols, row))
+ entry: dict[str, Any] = dict(zip(cols, row))
result.setdefault(row[0], []).append(entry)
return result
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 536af2d..38cd9b2 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -6,9 +6,6 @@
__author__ = 'Konstantin Ryabitsev <konstantin@linuxfoundation.org>'
import email.message
-import email.parser
-import email.policy
-import email.utils
import json
import os
import tempfile
@@ -22,7 +19,6 @@ from rich.text import Text
from textual.widgets import RichLog
import b4
-import b4.mbox
import b4.review
import b4.review.tracking
diff --git a/src/b4/review_tui/_modals.py b/src/b4/review_tui/_modals.py
index 0cd80e1..1ee1088 100644
--- a/src/b4/review_tui/_modals.py
+++ b/src/b4/review_tui/_modals.py
@@ -36,6 +36,9 @@ from textual.widgets import (
from textual.worker import Worker, WorkerState
import b4
+import b4.review
+import b4.review.tracking
+import b4.ty
from b4.review_tui._common import (
CI_CHECK_LABELS,
JKListNavMixin,
@@ -923,8 +926,6 @@ class TakeConfirmScreen(ModalScreen[bool]):
def _test_take(self) -> Tuple[bool, str]:
"""Test-apply review branch patches at the target base."""
- import b4.review
-
with _quiet_worker():
topdir = b4.git_get_toplevel()
if not topdir:
@@ -1414,8 +1415,6 @@ class QueueDeliveryScreen(ModalScreen[Optional[Tuple[int, int, List[Tuple[str, i
self._cancelled = True
def _do_deliver(self) -> Tuple[int, int, List[Tuple[str, int]]]:
- import b4.ty
-
def _on_progress(completed: int, total: int, status: str) -> None:
if not self._cancelled:
self.app.call_from_thread(self._update_progress, completed, total, status)
@@ -1609,7 +1608,8 @@ class ViewSeriesScreen(_FetchViewerScreen):
msgs = b4.review._retrieve_messages(self._message_id)
return b4.review._get_lore_series(msgs)
- def _show_result(self, lser: 'b4.LoreSeries') -> None:
+ def _show_result(self, result: 'b4.LoreSeries') -> None:
+ lser = result
subject = lser.subject or '(no subject)'
self.query_one('#fv-title', Static).update(subject)
viewer = self.query_one('#fv-viewer', RichLog)
@@ -1653,13 +1653,13 @@ class CIChecksScreen(_FetchViewerScreen):
self._series = series
def _fetch(self) -> List[Dict[str, Any]]:
- import b4.review
with _quiet_worker():
patch_ids = self._series.get('patch_ids', [])
return b4.review.pw_fetch_checks(
self._pwkey, self._pwurl, patch_ids)
- def _show_result(self, checks: List[Dict[str, Any]]) -> None:
+ def _show_result(self, result: List[Dict[str, Any]]) -> None:
+ checks = result
series_name = self._series.get('name') or '(no subject)'
self.query_one('#fv-title', Static).update(
f'CI checks \u2014 {series_name}')
@@ -1904,7 +1904,6 @@ class RebaseScreen(ModalScreen[bool]):
def _prepare_local(self) -> bytes:
"""Build mbox from the local review branch patches."""
- import b4.review
topdir = b4.git_get_toplevel()
if not topdir:
raise RuntimeError('Not in a git repository')
@@ -2633,8 +2632,6 @@ class UpdateAllScreen(ModalScreen[Dict[str, int]]):
self._cancelled = True
def _do_updates(self) -> Dict[str, int]:
- import b4.review
-
with _quiet_worker():
# Rescan local review branches first so the DB reflects current
# on-disk state before the network update runs.
diff --git a/src/b4/review_tui/_pw_app.py b/src/b4/review_tui/_pw_app.py
index 2b0c10a..0b510e7 100644
--- a/src/b4/review_tui/_pw_app.py
+++ b/src/b4/review_tui/_pw_app.py
@@ -462,7 +462,8 @@ class PwApp(App[None]):
callback=lambda res: self._on_apply_complete(res, item),
)
- def _on_apply_complete(self, result: Tuple[int, int, str], item: 'PwSeriesItem') -> None:
+ def _on_apply_complete(self, result: Optional[Tuple[int, int, str]], item: 'PwSeriesItem') -> None:
+ assert result is not None
ok, fail, new_state = result
if fail:
self.notify(f'{ok} updated, {fail} failed', severity='warning')
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index c14005b..8c2c110 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -1114,7 +1114,6 @@ class ReviewApp(CheckRunnerMixin, App[None]):
existing_reply = review.get('reply', '')
# Get the real diff for position resolution when parsing back
- real_diff = ''
if self._selected_idx > 0:
patch_idx = self._selected_idx - 1
if patch_idx >= len(self._commit_shas):
@@ -1125,18 +1124,11 @@ class ReviewApp(CheckRunnerMixin, App[None]):
if ecode > 0:
self.notify('Could not get diff', severity='error')
return
-
- if existing_reply:
- editor_text = existing_reply
- else:
- all_reviews = target.get('reviews', {})
- my_email = str(self._usercfg.get('email', ''))
- if self._selected_idx == 0:
- # Cover letter reply
- editor_text = b4.review._render_quoted_diff_with_comments(
- '', all_reviews, my_email,
- commit_msg=self._cover_text)
+ if existing_reply:
+ editor_text = existing_reply
else:
+ all_reviews = target.get('reviews', {})
+ my_email = str(self._usercfg.get('email', ''))
ecode, commit_msg = b4.git_run_command(
self._topdir, ['show', '--format=%B', '--no-patch', sha])
if ecode > 0:
@@ -1145,6 +1137,16 @@ class ReviewApp(CheckRunnerMixin, App[None]):
editor_text = b4.review._render_quoted_diff_with_comments(
real_diff, all_reviews, my_email,
commit_msg=commit_msg.strip())
+ else:
+ real_diff = ''
+ if existing_reply:
+ editor_text = existing_reply
+ else:
+ all_reviews = target.get('reviews', {})
+ my_email = str(self._usercfg.get('email', ''))
+ editor_text = b4.review._render_quoted_diff_with_comments(
+ '', all_reviews, my_email,
+ commit_msg=self._cover_text)
with self.suspend():
result = b4.edit_in_editor(
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 99de823..69c2769 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -9,7 +9,6 @@ import copy
import datetime
import email.message
import email.parser
-import email.policy
import email.utils
import io
import json
@@ -29,9 +28,11 @@ from textual.widgets import Footer, Label, ListItem, ListView, Static
from textual.worker import Worker, WorkerState
import b4
+import b4.ez
import b4.mbox
import b4.review
import b4.review.tracking
+import b4.ty
from b4.review_tui._common import (
CheckRunnerMixin,
SeparatedFooter,
@@ -788,7 +789,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
b4.review.tracking.unsnooze_series(conn, cid, prev_status, revision=rev)
def _load_series(self) -> None:
- import b4.ty
self._auto_wake_snoozed()
all_series = b4.review.tracking.get_all_tracked_series(self._identifier)
@@ -1507,13 +1507,13 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
if rstart and rend:
logger.info('Prepared fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend)
+ _is_rt = bool(series.get('is_rethreaded'))
try:
logger.info('Base: %s', base_commit)
b4.git_fetch_am_into_repo(topdir, ambytes=ambytes, at_base=base_commit,
origin=linkurl, am_flags=['-3'])
# Create the review branch
- _is_rt = bool(series.get('is_rethreaded'))
b4.review.create_review_branch(topdir, branch_name, base_commit, lser,
linkurl, linkmask, num_prereqs=0,
identifier=self._identifier,
@@ -1955,7 +1955,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
else:
self._show_take_screen(target_branch, change_id, review_branch, series)
- def _on_newer_revision_acknowledged(self, proceed: bool, target_branch: str,
+ def _on_newer_revision_acknowledged(self, proceed: Optional[bool], target_branch: str,
change_id: str, review_branch: str,
series: Dict[str, Any]) -> None:
"""Handle result of the newer-revision warning."""
@@ -2009,7 +2009,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
confirmed, change_id, review_branch, take_screen, series),
)
- def _on_take_confirmed(self, confirmed: bool, change_id: str,
+ def _on_take_confirmed(self, confirmed: Optional[bool], change_id: str,
review_branch: str, take_screen: 'TakeScreen',
series: Dict[str, Any]) -> None:
"""Handle take screen result — proceed to cherry-pick or confirm."""
@@ -2050,7 +2050,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
take_screen.method_result, take_screen.target_result,
change_id, review_branch, take_screen, series)
- def _on_cherrypick_confirmed(self, confirmed: bool, change_id: str,
+ def _on_cherrypick_confirmed(self, confirmed: Optional[bool], change_id: str,
review_branch: str, take_screen: 'TakeScreen',
series: Dict[str, Any],
pick_screen: 'CherryPickScreen') -> None:
@@ -2079,7 +2079,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
series, confirm_screen, cherrypick),
)
- def _on_take_final(self, confirmed: bool, method: str,
+ def _on_take_final(self, confirmed: Optional[bool], method: str,
change_id: str, review_branch: str,
take_screen: 'TakeScreen',
series: Dict[str, Any],
@@ -2629,7 +2629,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
confirmed, review_branch, rebase_screen),
)
- def _on_rebase_confirmed(self, confirmed: bool, review_branch: str,
+ def _on_rebase_confirmed(self, confirmed: Optional[bool], review_branch: str,
rebase_screen: 'RebaseScreen') -> None:
"""Handle rebase confirmation result."""
if not confirmed:
@@ -3061,7 +3061,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
revision=revision),
)
- def _on_abandon_confirmed(self, confirmed: bool, change_id: str,
+ def _on_abandon_confirmed(self, confirmed: Optional[bool], change_id: str,
review_branch: str, has_branch: bool,
revision: Optional[int] = None) -> None:
if not confirmed:
@@ -3429,12 +3429,12 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
logger.info('Prepared fake commit range for 3-way merge (%.12s..%.12s)', rstart, rend)
# --- 3. Apply to temporary upgrade branch ---
+ _is_rt = bool((self._selected_series or {}).get('is_rethreaded'))
try:
logger.info('Base: %s', base_sha)
b4.git_fetch_am_into_repo(topdir, ambytes=ambytes,
at_base=base_sha, origin=linkurl,
am_flags=['-3'])
- _is_rt = bool((self._selected_series or {}).get('is_rethreaded'))
b4.review.create_review_branch(topdir, upgrade_branch,
base_sha, lser, linkurl,
linkmask, num_prereqs=0,
@@ -3730,8 +3730,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
import tarfile
import time
- import b4.ez
-
topdir = b4.git_get_toplevel()
if not topdir:
if notify:
@@ -3811,7 +3809,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
self.notify(f'Archived {change_id}')
return True
- def _on_archive_confirmed(self, confirmed: bool, change_id: str,
+ def _on_archive_confirmed(self, confirmed: Optional[bool], change_id: str,
review_branch: str, has_branch: bool,
pw_series_id: Optional[int] = None,
revision: Optional[int] = None) -> None:
@@ -3829,9 +3827,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
"""Compose and preview a thank-you reply for a taken series."""
import argparse
- import b4.review
- import b4.ty
-
series = self._selected_series
if not series:
return
@@ -3955,8 +3950,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
def _queue_thank_message(self, msg: email.message.EmailMessage, checkurl: str) -> None:
"""Queue the thanks message for delivery once commits are public."""
- import b4.ty
-
series = self._selected_series
if not series:
return
@@ -4011,7 +4004,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
def _refresh_queue_indicator(self) -> None:
"""Update the title-bar queue count and Q binding visibility."""
- import b4.ty
self._queue_count = b4.ty.get_queued_count(dryrun=self._email_dryrun)
try:
right = self.query_one('#title-right', Static)
@@ -4025,7 +4017,6 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
def action_process_queue(self) -> None:
"""Show the queue modal and optionally deliver."""
- import b4.ty
entries = b4.ty.get_queued_messages(dryrun=self._email_dryrun)
if not entries:
self.notify('No queued thanks messages')
diff --git a/src/b4/ty.py b/src/b4/ty.py
index 74c094a..0d8adc2 100644
--- a/src/b4/ty.py
+++ b/src/b4/ty.py
@@ -507,9 +507,11 @@ def send_messages(listing: List[JsonDictT], branch: str, cmdargs: argparse.Names
else:
logger.info('Sent %s thank-you letters', outgoing)
if pwstate:
+ assert isinstance(pwstate, str), 'pwstate must be a string'
b4.patchwork_set_state(msgids, pwstate)
else:
if pwstate and not cmdargs.dryrun:
+ assert isinstance(pwstate, str), 'pwstate must be a string'
b4.patchwork_set_state(msgids, pwstate)
logger.info('---')
logger.debug('Wrote %s thank-you letters', outgoing)
@@ -614,12 +616,14 @@ def discard_selected(cmdargs: argparse.Namespace) -> None:
logger.info('Discarding %s messages', len(listing))
msgids: List[str] = list()
for jsondata in listing:
- assert isinstance(jsondata['trackfile'], str), 'trackfile must be a string'
- fullpath = os.path.join(datadir, jsondata['trackfile'])
+ trackfile = jsondata['trackfile']
+ assert isinstance(trackfile, str), 'trackfile must be a string'
+ fullpath = os.path.join(datadir, trackfile)
os.rename(fullpath, '%s.discarded' % fullpath)
logger.info(' Discarded: %s', jsondata['subject'])
- assert isinstance(jsondata['msgid'], str), 'msgid must be a string'
- msgids.append(jsondata['msgid'])
+ msgid = jsondata['msgid']
+ assert isinstance(msgid, str), 'msgid must be a string'
+ msgids.append(msgid)
patches = cast(List[Tuple[str, str, str, str]], jsondata['patches'])
for pdata in patches:
msgids.append(pdata[2])
@@ -629,6 +633,7 @@ def discard_selected(cmdargs: argparse.Namespace) -> None:
if not pwstate:
pwstate = config.get('pw-discard-state')
if pwstate:
+ assert isinstance(pwstate, str), 'pwstate must be a string'
b4.patchwork_set_state(msgids, pwstate)
sys.exit(0)
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index cb01795..cf1a699 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -1,5 +1,7 @@
import email
-import email.parser
+import email.message
+import email.policy
+import email.utils
import io
import os
import pathlib
diff --git a/src/tests/test_review_tracking.py b/src/tests/test_review_tracking.py
index 181b5e5..6734a1c 100644
--- a/src/tests/test_review_tracking.py
+++ b/src/tests/test_review_tracking.py
@@ -4,7 +4,7 @@ import io
import os
import re
from email.message import EmailMessage
-from typing import Any, Dict, List, Union
+from typing import Any, Dict
from unittest import mock
import pytest
@@ -1724,13 +1724,14 @@ class TestFollowupBlob:
class TestPatchState:
"""Tests for _get_patch_state() and _set_patch_state()."""
- _USERCFG: Dict[str, Union[str, List[str], None]] = {'email': 'reviewer@example.com', 'name': 'Test Reviewer'}
+ _EMAIL = 'reviewer@example.com'
+ _USERCFG: b4.ConfigDictT = {'email': _EMAIL, 'name': 'Test Reviewer'}
def _make_target(self, review_data: Dict[str, Any] | None = None) -> Dict[str, Any]:
"""Return a minimal target dict, optionally with review data."""
if review_data is None:
return {}
- return {'reviews': {self._USERCFG['email']: {'name': 'Test Reviewer', **review_data}}}
+ return {'reviews': {self._EMAIL: {'name': 'Test Reviewer', **review_data}}}
def test_no_data(self) -> None:
"""Empty reviews dict → no state."""
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 09/12] Avoid duplicate map lookups
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (7 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 08/12] Enable and fix pyright diagnostics Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 10/12] Add ty and configuration Tamir Duberstein
` (3 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Use dict.get and defaultdict to avoid repeated membership checks before
indexing into the same map. This keeps the existing behavior while
making later type narrowing easier for mypy and pyright.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
src/b4/__init__.py | 100 ++++++++++++++++++++++++++---------------------------
src/b4/ez.py | 10 +++---
2 files changed, 55 insertions(+), 55 deletions(-)
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index f38787b..e6eae3e 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -29,6 +29,7 @@ import tempfile
import textwrap
import time
import urllib.parse
+from collections import defaultdict
from contextlib import contextmanager
from pathlib import Path
from typing import (
@@ -190,7 +191,7 @@ class LoreMailbox:
msgid_map: Dict[str, 'LoreMessage']
series: Dict[int, 'LoreSeries']
covers: Dict[int, 'LoreMessage']
- trailer_map: Dict[str, List['LoreMessage']]
+ trailer_map: defaultdict[str, List['LoreMessage']]
followups: List['LoreMessage']
unknowns: List['LoreMessage']
@@ -198,7 +199,7 @@ class LoreMailbox:
self.msgid_map = dict()
self.series = dict()
self.covers = dict()
- self.trailer_map = dict()
+ self.trailer_map = defaultdict(list)
self.followups = list()
self.unknowns = list()
@@ -215,11 +216,6 @@ class LoreMailbox:
return '\n'.join(out)
- def get_by_msgid(self, msgid: str) -> Optional['LoreMessage']:
- if msgid in self.msgid_map:
- return self.msgid_map[msgid]
- return None
-
def partial_reroll(self, revision: int, sloppytrailers: bool) -> None:
# Is it a partial reroll?
# To qualify for a partial reroll:
@@ -236,11 +232,13 @@ class LoreMailbox:
for patch in lser.patches:
if patch is None:
continue
- if patch.in_reply_to is None or patch.in_reply_to not in self.msgid_map:
+ if (
+ patch.in_reply_to is None
+ or (ppatch := self.msgid_map.get(patch.in_reply_to)) is None
+ ):
logger.debug('Patch not sent as a reply-to')
sane = False
break
- ppatch = self.msgid_map[patch.in_reply_to]
found = False
while True:
if patch.counter == ppatch.counter and patch.expected == ppatch.expected:
@@ -248,9 +246,12 @@ class LoreMailbox:
found = True
break
# Do we have another level up?
- if ppatch.in_reply_to is None or ppatch.in_reply_to not in self.msgid_map:
+ if (
+ ppatch.in_reply_to is None
+ or (npatch := self.msgid_map.get(ppatch.in_reply_to)) is None
+ ):
break
- ppatch = self.msgid_map[ppatch.in_reply_to]
+ ppatch = npatch
if not found:
sane = False
@@ -305,9 +306,7 @@ class LoreMailbox:
logger.debug('Retrieved %s matching code-review messages', len(qmsgs))
patchid_map = map_codereview_trailers(qmsgs, ignore_msgids=set(self.msgid_map.keys()))
for patchid, fmsgs in patchid_map.items():
- if patchid not in self.trailer_map:
- self.trailer_map[patchid] = list()
- self.trailer_map[patchid] += fmsgs
+ self.trailer_map[patchid].extend(fmsgs)
def get_latest_revision(self) -> Optional[int]:
@@ -328,10 +327,10 @@ class LoreMailbox:
revision = self.get_latest_revision()
if revision is None:
return None
- elif revision not in self.series:
- return None
- lser = self.series[revision]
+ lser = self.series.get(revision)
+ if lser is None:
+ return None
# Is it empty?
empty = True
@@ -347,15 +346,15 @@ class LoreMailbox:
self.partial_reroll(revision, sloppytrailers)
# Grab our cover letter if we have one
- if revision in self.covers:
- lser.add_patch(self.covers[revision])
+ if (cover := self.covers.get(revision)) is not None:
+ lser.add_patch(cover)
lser.has_cover = True
else:
# Let's find the first patch with an in-reply-to and see if that
# is our cover letter
for member in lser.patches:
if member is not None and member.in_reply_to is not None:
- potential = self.get_by_msgid(member.in_reply_to)
+ potential = self.msgid_map.get(member.in_reply_to)
if potential is not None and potential.has_diffstat and not potential.has_diff:
# This is *probably* the cover letter
lser.patches[0] = potential
@@ -380,12 +379,13 @@ class LoreMailbox:
if fmsg.in_reply_to is None:
# Check if there's something matching in References
for refid in fmsg.references:
- if refid in self.msgid_map and refid != fmsg.msgid:
- pmsg = self.msgid_map[refid]
+ if (
+ refid != fmsg.msgid
+ and (pmsg := self.msgid_map.get(refid)) is not None
+ ):
logger.debug('Found a references entry %s in msgid_map', refid)
break
- elif fmsg.in_reply_to in self.msgid_map:
- pmsg = self.msgid_map[fmsg.in_reply_to]
+ elif (pmsg := self.msgid_map.get(fmsg.in_reply_to)) is not None:
logger.debug('Found in-reply-to %s in msgid_map', fmsg.in_reply_to)
if pmsg is None:
# Can't find the message we're replying to here
@@ -407,8 +407,6 @@ class LoreMailbox:
# previous revisions to current revision if patch id did
# not change
if pmsg.git_patch_id:
- if pmsg.git_patch_id not in self.trailer_map:
- self.trailer_map[pmsg.git_patch_id] = list()
self.trailer_map[pmsg.git_patch_id].append(fmsg)
pmsg.followup_trailers += trailers
break
@@ -416,15 +414,18 @@ class LoreMailbox:
# Could be a cover letter
pmsg.followup_trailers += trailers
break
- if pmsg.in_reply_to and pmsg.in_reply_to in self.msgid_map:
+ if (
+ pmsg.in_reply_to
+ and (nmsg := self.msgid_map.get(pmsg.in_reply_to)) is not None
+ ):
# Avoid bad message id causing infinite loop
- if pmsg == self.msgid_map[pmsg.in_reply_to]:
+ if pmsg == nmsg:
break
lvl += 1
for pltr in pmsg.trailers:
pltr.lmsg = pmsg
trailers.append(pltr)
- pmsg = self.msgid_map[pmsg.in_reply_to]
+ pmsg = nmsg
continue
break
@@ -434,25 +435,24 @@ class LoreMailbox:
if lmsg is None or lmsg.git_patch_id is None:
continue
logger.debug(' matching patch_id %s from: %s', lmsg.git_patch_id, lmsg.full_subject)
- if lmsg.git_patch_id in self.trailer_map:
- for fmsg in self.trailer_map[lmsg.git_patch_id]:
- logger.debug(' matched: %s', fmsg.msgid)
- fltrs, fmis = fmsg.get_trailers(sloppy=sloppytrailers)
- for fltr in fltrs:
- if fltr in lmsg.trailers:
- logger.debug(' trailer already exists')
- continue
- if fltr in lmsg.followup_trailers:
- logger.debug(' identical trailer received for this series')
- continue
- logger.debug(' carrying over the trailer to this series (may be duplicate)')
- logger.debug(' %s', lmsg.full_subject)
- logger.debug(' + %s', fltr.as_string())
- if fltr.lmsg:
- logger.debug(' via: %s', fltr.lmsg.msgid)
- lmsg.followup_trailers.append(fltr)
- for fltr in fmis:
- lser.trailer_mismatches.add((fltr.name, fltr.value, fmsg.fromname, fmsg.fromemail))
+ for fmsg in self.trailer_map.get(lmsg.git_patch_id, ()):
+ logger.debug(' matched: %s', fmsg.msgid)
+ fltrs, fmis = fmsg.get_trailers(sloppy=sloppytrailers)
+ for fltr in fltrs:
+ if fltr in lmsg.trailers:
+ logger.debug(' trailer already exists')
+ continue
+ if fltr in lmsg.followup_trailers:
+ logger.debug(' identical trailer received for this series')
+ continue
+ logger.debug(' carrying over the trailer to this series (may be duplicate)')
+ logger.debug(' %s', lmsg.full_subject)
+ logger.debug(' + %s', fltr.as_string())
+ if fltr.lmsg:
+ logger.debug(' via: %s', fltr.lmsg.msgid)
+ lmsg.followup_trailers.append(fltr)
+ for fltr in fmis:
+ lser.trailer_mismatches.add((fltr.name, fltr.value, fmsg.fromname, fmsg.fromemail))
return lser
@@ -487,7 +487,7 @@ class LoreMailbox:
if lmsg.revision_inferred and lmsg.in_reply_to:
# We have an inferred revision here.
# Do we have an upthread cover letter that specifies a revision?
- irt = self.get_by_msgid(lmsg.in_reply_to)
+ irt = self.msgid_map.get(lmsg.in_reply_to)
if irt is not None and irt.has_diffstat and not irt.has_diff:
# Yes, this is very likely our cover letter
logger.debug(' fixed revision to v%s', irt.revision)
@@ -3396,7 +3396,7 @@ def get_config_from_git(regexp: str, defaults: Optional[Dict[str, Any]] = None,
chunks = key.split('.')
cfgkey = chunks[-1].lower()
if cfgkey in multivals:
- if cfgkey not in gitconfig or gitconfig[cfgkey] is None:
+ if gitconfig.get(cfgkey) is None:
gitconfig[cfgkey] = list()
gitconfig[cfgkey].append(value)
else:
diff --git a/src/b4/ez.py b/src/b4/ez.py
index b54e04c..51ded98 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -26,6 +26,7 @@ import textwrap
import time
import urllib.parse
import uuid
+from collections import defaultdict
from email.message import EmailMessage
from string import Template
from typing import Any, Dict, List, Optional, Set, Tuple, Union
@@ -1988,7 +1989,7 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
seen: Set[str] = set()
excludes: Set[str] = set()
- pccs: Dict[str, List[Tuple[str, str]]] = dict()
+ pccs: defaultdict[str, List[Tuple[str, str]]] = defaultdict(list)
if cmdargs.preview_to or cmdargs.no_trailer_to_cc:
todests = list()
@@ -2011,10 +2012,9 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
if btr.addr[1] in seen:
continue
if commit:
- if commit not in pccs:
- pccs[commit] = list()
- if btr.addr not in pccs[commit]:
- pccs[commit].append(btr.addr)
+ cpccs = pccs[commit]
+ if btr.addr not in cpccs:
+ cpccs.append(btr.addr)
continue
seen.add(btr.addr[1])
if btr.lname == 'to':
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 10/12] Add ty and configuration
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (8 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 09/12] Avoid duplicate map lookups Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 11/12] Enable pyright strict mode Tamir Duberstein
` (2 subsequent siblings)
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
This revealed usage that assumed python >= 3.11 such as `|` unions and a
particular overload of `wsgiref.simple_server.make_server`.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/send-receive.py | 2 +
pyproject.toml | 6 ++-
src/b4/__init__.py | 52 +++++++++++-------
src/b4/command.py | 12 ++---
src/b4/dig.py | 4 +-
src/b4/ez.py | 2 +-
src/b4/mbox.py | 5 +-
src/b4/pr.py | 4 ++
src/b4/review/checks.py | 16 +++---
src/b4/review_tui/_common.py | 106 +++++++++++++++++++++++++++++++------
src/b4/review_tui/_review_app.py | 7 ++-
src/b4/review_tui/_tracking_app.py | 3 +-
src/b4/tui/_common.py | 18 ++++---
src/b4/ty.py | 5 +-
src/tests/test___init__.py | 4 +-
src/tests/test_tui_tracking.py | 3 +-
16 files changed, 180 insertions(+), 69 deletions(-)
diff --git a/misc/send-receive.py b/misc/send-receive.py
index 3f00f09..330bbae 100644
--- a/misc/send-receive.py
+++ b/misc/send-receive.py
@@ -700,6 +700,8 @@ class SendReceiveListener(object):
resp.content_type = falcon.MEDIA_TEXT
resp.text = 'Failed to parse the request\n'
return
+ # TODO(https://github.com/astral-sh/ruff/pull/24458): remove this when ty understands conditional walrus.
+ action = None
if not isinstance(jdata, Mapping) or (action := jdata.get('action')) is None:
logger.critical('Action not set from %s', req.remote_addr)
return
diff --git a/pyproject.toml b/pyproject.toml
index 389e573..25338e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -12,7 +12,7 @@ license = {file = "COPYING"}
authors = [
{name = "Konstantin Ryabitsev", email="konstantin@linuxfoundation.org"},
]
-requires-python = ">=3.9"
+requires-python = ">=3.11"
classifiers = [
"Environment :: Console",
"Operating System :: POSIX :: Linux",
@@ -40,6 +40,7 @@ dev = [
"pytest",
"pytest-asyncio",
"ruff",
+ "ty",
"types-requests",
]
misc = [
@@ -126,3 +127,6 @@ warn_unreachable = true
[tool.pyright]
typeCheckingMode = "standard"
reportUnusedImport = true
+
+[tool.ty.rules]
+all = "error"
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index e6eae3e..c9b05d8 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -57,7 +57,7 @@ import patatt
ConfigDictT = Dict[str, Union[str, List[str], None]]
from email import charset
-from email.message import EmailMessage
+from email.message import EmailMessage, Message
charset.add_charset('utf-8', None)
# Policy we use for saving mail locally
@@ -68,9 +68,9 @@ emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length
qspecials = re.compile(r'[()<>@,:;.\"\[\]]')
# global setting allowing us to turn off networking
-can_network = True
+can_network: bool = True
-__VERSION__ = '0.16-dev'
+__VERSION__: str = '0.16-dev'
PW_REST_API_VERSION = '1.2'
@@ -229,6 +229,8 @@ class LoreMailbox:
# Are existing patches replies to previous revisions with the same counter?
lser = self.series[revision]
sane = True
+ # TODO(https://github.com/astral-sh/ruff/pull/24458): remove this when ty understands conditional walrus.
+ ppatch = None
for patch in lser.patches:
if patch is None:
continue
@@ -246,6 +248,8 @@ class LoreMailbox:
found = True
break
# Do we have another level up?
+ # TODO(https://github.com/astral-sh/ruff/pull/24458): remove this when ty understands conditional walrus.
+ npatch = None
if (
ppatch.in_reply_to is None
or (npatch := self.msgid_map.get(ppatch.in_reply_to)) is None
@@ -414,6 +418,8 @@ class LoreMailbox:
# Could be a cover letter
pmsg.followup_trailers += trailers
break
+ # TODO(https://github.com/astral-sh/ruff/pull/24458): remove this when ty understands conditional walrus.
+ nmsg = None
if (
pmsg.in_reply_to
and (nmsg := self.msgid_map.get(pmsg.in_reply_to)) is not None
@@ -1673,10 +1679,10 @@ class LoreMessage:
# Identify all DKIM-Signature headers and try them in reverse order
# until we come to a passing one
dkhdrs = list()
- for header in list(self.msg._headers): # type: ignore[attr-defined]
+ for header in list(self.msg._headers): # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
if header[0].lower() == 'dkim-signature':
dkhdrs.append(header)
- self.msg._headers.remove(header) # type: ignore[attr-defined]
+ self.msg._headers.remove(header) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
dkhdrs.reverse()
seenatts = list()
@@ -1706,7 +1712,7 @@ class LoreMessage:
if isinstance(sh, str) and 'date' in sh.lower().split(':'):
signtime = self.date
- self.msg._headers.append((hn, hval)) # type: ignore[attr-defined]
+ self.msg._headers.append((hn, hval)) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
try:
res = dkim.verify(self.msg.as_bytes(policy=emlpolicy), logger=dkimlogger)
logger.debug('DKIM verify results: %s=%s', identity, res)
@@ -1723,7 +1729,7 @@ class LoreMessage:
self._attestors.append(attestor)
return
- self.msg._headers.pop(-1) # type: ignore[attr-defined]
+ self.msg._headers.pop(-1) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
seenatts.append(attestor)
# No exact domain matches, so return everything we have
@@ -2012,9 +2018,9 @@ class LoreMessage:
self.fromname = xpair[0]
self.fromemail = xpair[1]
# Drop the reply-to header if it's exactly the same
- for header in list(self.msg._headers): # type: ignore[attr-defined]
+ for header in list(self.msg._headers): # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
if header[0].lower() == 'reply-to' and header[1].find(xpair[1]) > 0:
- self.msg._headers.remove(header) # type: ignore[attr-defined]
+ self.msg._headers.remove(header) # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
has_passing = True
att_info: Dict[str, Any] = {
@@ -2356,7 +2362,7 @@ class LoreMessage:
return hdata
@staticmethod
- def get_clean_msgid(msg: EmailMessage, header: str = 'Message-Id') -> Optional[str]:
+ def get_clean_msgid(msg: Message, header: str = 'Message-Id') -> Optional[str]:
msgid = None
raw = msg.get(header)
if raw:
@@ -3240,8 +3246,8 @@ def git_run_command(gitdir: Optional[Union[str, Path]], args: List[str], stdin:
U = TypeVar('U', str, bytes)
- def _handle(_out: U, _err: U) -> Tuple[int, Union[str, bytes]]:
- if logstderr and len(_err.strip()):
+ def _handle(_out: U, _err: U) -> Tuple[int, U]:
+ if logstderr and len(_err.strip()): # ty:ignore[no-matching-overload, invalid-argument-type] # https://github.com/astral-sh/ty/issues/1503
logger.debug('Stderr: %s', _err)
_out += _err
@@ -4694,10 +4700,17 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs
if isinstance(smtp, list):
# This is a local command
+
+ # This a little crazy but it's possible, through multiple inheritance,
+ # for smtp to be a list of something other than str if it is also one of
+ # the other types in the union.
+ #
+ # https://github.com/astral-sh/ty/issues/1578
+ smtps = ' '.join(smtp) # ty:ignore[no-matching-overload]
if reflect:
- logger.info('Reflecting via "%s"', ' '.join(smtp))
+ logger.info('Reflecting via "%s"', smtps)
else:
- logger.info('Sending via "%s"', ' '.join(smtp))
+ logger.info('Sending via "%s"', smtps)
for destaddrs, bdata, lsubject in tosend:
logger.info(' %s', lsubject.full_subject)
if reflect:
@@ -4706,7 +4719,8 @@ def send_mail(smtp: Union[smtplib.SMTP, smtplib.SMTP_SSL, List[str], None], msgs
cmdargs = list(smtp) + list(destaddrs)
ecode, _out, err = _run_command(cmdargs, stdin=bdata)
if ecode > 0:
- raise RuntimeError('Error running %s: %s' % (' '.join(smtp), err.decode()))
+ raise RuntimeError(
+ 'Error running %s: %s' % (smtps, err.decode()))
sent += 1
elif smtp:
@@ -5370,11 +5384,11 @@ def mailbox_email_factory(fh: BinaryIO) -> EmailMessage:
def get_msgs_from_mailbox_or_maildir(mbmd: str) -> List[EmailMessage]:
if is_maildir(mbmd):
- in_mdr = mailbox.Maildir(mbmd, factory=mailbox_email_factory) # type: ignore[arg-type]
- return [x[1] for x in in_mdr.items()] # type: ignore[misc]
+ in_mdr = mailbox.Maildir(mbmd, factory=mailbox_email_factory) # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
+ return [x[1] for x in in_mdr.items()] # type: ignore[misc] # ty: ignore[invalid-return-type]
- in_mbx = mailbox.mbox(mbmd, factory=mailbox_email_factory) # type: ignore[arg-type]
- return[x[1] for x in in_mbx.items()] # type: ignore[misc]
+ in_mbx = mailbox.mbox(mbmd, factory=mailbox_email_factory) # type: ignore[arg-type] # ty: ignore[invalid-argument-type]
+ return [x[1] for x in in_mbx.items()] # type: ignore[misc] # ty: ignore[invalid-return-type]
def get_mailfrom() -> Tuple[str, str]:
diff --git a/src/b4/command.py b/src/b4/command.py
index fc55c71..c820d61 100644
--- a/src/b4/command.py
+++ b/src/b4/command.py
@@ -142,7 +142,7 @@ class ConfigOption(argparse.Action):
"""Action class for storing key=value arguments in a dict."""
def __call__(self, parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
- keyval: Union[str, Sequence[Any], None],
+ values: Union[str, Sequence[Any], None],
option_string: Optional[str] = None) -> None:
config = getattr(namespace, self.dest, None)
@@ -150,13 +150,13 @@ class ConfigOption(argparse.Action):
config = dict()
setattr(namespace, self.dest, config)
- if not isinstance(keyval, str):
- raise TypeError(f'Expected a string config assignment, got {keyval!r}')
+ if not isinstance(values, str):
+ raise TypeError(f'Expected a string config assignment, got {values!r}')
- if '=' in keyval:
- key, value = keyval.split('=', maxsplit=1)
+ if '=' in values:
+ key, value = values.split('=', maxsplit=1)
else:
- key, value = keyval, 'true'
+ key, value = values, 'true'
config[key] = value
diff --git a/src/b4/dig.py b/src/b4/dig.py
index 46e2510..422c56f 100644
--- a/src/b4/dig.py
+++ b/src/b4/dig.py
@@ -276,7 +276,7 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None:
if not best_match:
# Next, try to find by exact patch-id
for lmsg in all_lmsgs:
- if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # broken since 3ae277e9c7dd3e1df61a14884aabdd5834ad1201
+ if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # ty:ignore[possibly-unresolved-reference] # broken since 3ae277e9c7dd3e1df61a14884aabdd5834ad1201
logger.debug('matched by exact patch-id')
best_match = lmsg
break
@@ -331,7 +331,7 @@ def dig_commitish(cmdargs: argparse.Namespace) -> None:
continue
if firstmsg is None:
firstmsg = lmsg
- if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # broken since inception in 16329336c1c8faba853b11238a16249306742505
+ if lmsg.git_patch_id == patch_id: # pyright: ignore[reportPossiblyUnboundVariable] # ty:ignore[possibly-unresolved-reference] # broken since inception in 16329336c1c8faba853b11238a16249306742505
logger.debug('Matched by exact patch-id')
break
if lmsg.subject == csubj:
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 51ded98..9f55bc9 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -1306,7 +1306,7 @@ def update_trailers(cmdargs: argparse.Namespace) -> None:
if fltr.lmsg is not None:
source = midmask % urllib.parse.quote_plus(fltr.lmsg.msgid, safe='@')
logger.info(' + %s', rendered)
- logger.info(' via: %s', source) # pyright: ignore[reportPossiblyUnboundVariable] # broken since 742e017c1b5b91d0e6fd6fca7decf73956b31487
+ logger.info(' via: %s', source) # pyright: ignore[reportPossiblyUnboundVariable] # ty:ignore[possibly-unresolved-reference] # broken since 742e017c1b5b91d0e6fd6fca7decf73956b31487
else:
logger.debug(' . %s', fltr.as_string(omit_extinfo=True))
diff --git a/src/b4/mbox.py b/src/b4/mbox.py
index 5e8093a..a73c995 100644
--- a/src/b4/mbox.py
+++ b/src/b4/mbox.py
@@ -49,7 +49,7 @@ def save_msgs_as_mbox(dest: str, msgs: List[EmailMessage], filterdupes: bool = F
added = 0
if filterdupes:
for emsg in mdr:
- have_msgids.add(b4.LoreMessage.get_clean_msgid(emsg)) # type: ignore[arg-type]
+ have_msgids.add(b4.LoreMessage.get_clean_msgid(emsg))
for msg in msgs:
if b4.LoreMessage.get_clean_msgid(msg) not in have_msgids:
added += 1
@@ -829,8 +829,7 @@ def refetch(dest: str) -> None:
by_msgid: Dict[str, EmailMessage] = dict()
for key, msg in mbox.items():
- # We normally pass EmailMessage objects, but this works, too
- msgid = b4.LoreMessage.get_clean_msgid(msg) # type: ignore[arg-type]
+ msgid = b4.LoreMessage.get_clean_msgid(msg)
if not msgid:
continue
if msgid not in by_msgid:
diff --git a/src/b4/pr.py b/src/b4/pr.py
index 939c1f5..469cc8e 100644
--- a/src/b4/pr.py
+++ b/src/b4/pr.py
@@ -458,6 +458,7 @@ def main(cmdargs: argparse.Namespace) -> None:
if msgs:
if cmdargs.sendidentity:
+ assert isinstance(cmdargs.sendidentity, str)
# Pass exploded series via git-send-email
config = b4.get_config_from_git(rf'sendemail\.{cmdargs.sendidentity}\..*')
if not len(config):
@@ -465,6 +466,9 @@ def main(cmdargs: argparse.Namespace) -> None:
sys.exit(1)
# Make sure from is not overridden by current user
mailfrom = msgs[0].get('from')
+ if not isinstance(mailfrom, str):
+ logger.critical('Expected a string From header in exploded message, got %r', mailfrom)
+ sys.exit(1)
gitargs = ['send-email', '--identity', cmdargs.sendidentity, '--from', mailfrom]
if cmdargs.dryrun:
gitargs.append('--dry-run')
diff --git a/src/b4/review/checks.py b/src/b4/review/checks.py
index 2ea5027..aec5de4 100644
--- a/src/b4/review/checks.py
+++ b/src/b4/review/checks.py
@@ -13,7 +13,7 @@ import pathlib
import shlex
import sqlite3
from email.message import EmailMessage
-from typing import Any, Dict, List, Optional, Tuple
+from typing import Any, Dict, List, Optional, Tuple, Union
import requests
@@ -153,12 +153,14 @@ def load_check_cmds() -> Tuple[List[str], List[str]]:
"""
config = b4.get_main_config()
- def _as_list(val: Any) -> List[str]:
- if isinstance(val, str):
- return [val]
- if isinstance(val, list):
- return list(val)
- return []
+ def _as_list(val: Union[str, List[str], None]) -> List[str]:
+ match val:
+ case str():
+ return [val]
+ case list():
+ return val
+ case None:
+ return []
perpatch = _as_list(config.get('review-perpatch-check-cmd'))
if not perpatch:
diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py
index 38cd9b2..5ec0157 100644
--- a/src/b4/review_tui/_common.py
+++ b/src/b4/review_tui/_common.py
@@ -9,7 +9,20 @@ import email.message
import json
import os
import tempfile
-from typing import Any, Dict, List, Optional, Set, Tuple
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ List,
+ Optional,
+ ParamSpec,
+ Protocol,
+ Set,
+ Tuple,
+ TypeVar,
+)
from rich import box
from rich.padding import Padding
@@ -17,6 +30,7 @@ from rich.panel import Panel
from rich.rule import Rule
from rich.text import Text
from textual.widgets import RichLog
+from textual.worker import Worker
import b4
import b4.review
@@ -24,6 +38,13 @@ import b4.review.tracking
logger = b4.logger
+if TYPE_CHECKING:
+ from b4.review_tui._modals import CheckLoadingScreen
+
+_CallFromThreadParams = ParamSpec('_CallFromThreadParams')
+_CallFromThreadReturn = TypeVar('_CallFromThreadReturn')
+_WorkerResult = TypeVar('_WorkerResult')
+
def get_thread_msgs(
topdir: str,
@@ -124,6 +145,61 @@ from b4.tui._common import (
)
+class _CallFromThreadHost(Protocol):
+ def call_from_thread(
+ self,
+ callback: Callable[
+ _CallFromThreadParams,
+ _CallFromThreadReturn | Awaitable[_CallFromThreadReturn],
+ ],
+ *args: _CallFromThreadParams.args,
+ **kwargs: _CallFromThreadParams.kwargs,
+ ) -> _CallFromThreadReturn: ...
+
+
+class _CheckRunnerHost(Protocol):
+ _check_loading: Optional['CheckLoadingScreen']
+
+ @property
+ def app(self) -> _CallFromThreadHost: ...
+
+ def _get_check_context(self) -> Optional[Tuple[str, str, str]]: ...
+
+ def _run_checks(self, force: bool = ...) -> None: ...
+
+ def _dismiss_loading(self, msg: str = ..., severity: str = ...) -> None: ...
+
+ def _update_loading(self, text: str) -> None: ...
+
+ def _fetch_and_check(
+ self,
+ message_id: str,
+ series_subject: str,
+ change_id: str = '',
+ force: bool = ...,
+ ) -> None: ...
+
+ def notify(self, message: str, *, severity: str = ...) -> None: ...
+
+ def push_screen(
+ self,
+ screen: object,
+ callback: Optional[Callable[[Optional[str]], None]] = ...,
+ ) -> object: ...
+
+ def run_worker(
+ self,
+ work: Callable[[], _WorkerResult],
+ name: Optional[str] = ...,
+ group: str = ...,
+ description: str = ...,
+ exit_on_error: bool = ...,
+ start: bool = ...,
+ exclusive: bool = ...,
+ thread: bool = ...,
+ ) -> Worker[_WorkerResult]: ...
+
+
class CheckRunnerMixin:
"""Mixin providing CI check execution for Textual App subclasses.
@@ -132,7 +208,7 @@ class CheckRunnerMixin:
interaction (loading overlay, results modal) is handled here.
"""
- _check_loading: Optional[Any] = None
+ _check_loading: Optional['CheckLoadingScreen']
# -- interface for subclasses ------------------------------------------
@@ -145,46 +221,46 @@ class CheckRunnerMixin:
# -- public action -----------------------------------------------------
- def action_check(self) -> None:
+ def action_check(self: _CheckRunnerHost) -> None:
"""Run CI checks on the current series."""
self._run_checks(force=False)
# -- helpers -----------------------------------------------------------
- def _run_checks(self, force: bool = False) -> None:
+ def _run_checks(self: _CheckRunnerHost, force: bool = False) -> None:
"""Show loading overlay and launch the check worker thread."""
ctx = self._get_check_context()
if ctx is None:
return
message_id, series_subject, change_id = ctx
if not message_id:
- self.notify('No message-id for this series', severity='error') # type: ignore[attr-defined]
+ self.notify('No message-id for this series', severity='error')
return
from b4.review_tui._modals import CheckLoadingScreen
self._check_loading = CheckLoadingScreen()
- self.push_screen(self._check_loading) # type: ignore[attr-defined]
- self.run_worker( # type: ignore[attr-defined]
+ self.push_screen(self._check_loading)
+ self.run_worker(
lambda: self._fetch_and_check(message_id, series_subject,
change_id=change_id, force=force),
name='_check_worker', thread=True)
- def _dismiss_loading(self, msg: str = '', severity: str = '') -> None:
+ def _dismiss_loading(self: _CheckRunnerHost, msg: str = '', severity: str = '') -> None:
"""Dismiss the check loading screen and optionally notify."""
def _do() -> None:
if self._check_loading is not None and self._check_loading.is_attached:
self._check_loading.dismiss(None)
if msg:
- self.notify(msg, severity=severity) # type: ignore[attr-defined]
- self.app.call_from_thread(_do) # type: ignore[attr-defined]
+ self.notify(msg, severity=severity)
+ self.app.call_from_thread(_do)
- def _update_loading(self, text: str) -> None:
+ def _update_loading(self: _CheckRunnerHost, text: str) -> None:
"""Update the loading screen status text."""
def _do() -> None:
if self._check_loading is not None and self._check_loading.is_attached:
self._check_loading.update_status(text)
- self.app.call_from_thread(_do) # type: ignore[attr-defined]
+ self.app.call_from_thread(_do)
- def _fetch_and_check(self, message_id: str, series_subject: str,
+ def _fetch_and_check(self: _CheckRunnerHost, message_id: str, series_subject: str,
change_id: str = '', force: bool = False) -> None:
"""Fetch thread, run checks, and push results modal (worker thread)."""
import b4.review.checks as checks
@@ -355,11 +431,11 @@ class CheckRunnerMixin:
def _push_modal() -> None:
if self._check_loading is not None and self._check_loading.is_attached:
self._check_loading.dismiss(None)
- self.push_screen(TrackingCheckResultsScreen( # type: ignore[attr-defined]
+ self.push_screen(TrackingCheckResultsScreen(
title, patch_labels, patch_subjects, tools_sorted, matrix),
callback=_on_result)
- self.app.call_from_thread(_push_modal) # type: ignore[attr-defined]
+ self.app.call_from_thread(_push_modal)
def _make_initials(name: str) -> str:
diff --git a/src/b4/review_tui/_review_app.py b/src/b4/review_tui/_review_app.py
index 8c2c110..c13d899 100644
--- a/src/b4/review_tui/_review_app.py
+++ b/src/b4/review_tui/_review_app.py
@@ -47,6 +47,7 @@ from b4.review_tui._common import (
reviewer_colours,
)
from b4.review_tui._modals import (
+ CheckLoadingScreen,
FollowupReplyPreviewScreen,
HelpScreen,
NoteScreen,
@@ -282,7 +283,7 @@ class ReviewApp(CheckRunnerMixin, App[None]):
self._collapsed_comment_lines: Dict[int, Tuple[str, int]] = {}
self._reply_sent: bool = False
self._hide_skipped: bool = False
- self._check_loading: Optional[Any] = None
+ self._check_loading: Optional[CheckLoadingScreen] = None
def _get_check_context(self) -> Optional[Tuple[str, str, str]]:
message_id = self._series.get('header-info', {}).get('msgid', '')
@@ -336,7 +337,9 @@ class ReviewApp(CheckRunnerMixin, App[None]):
widget.update(f' WARNING: newer version(s) available: {versions}')
widget.styles.display = 'block'
else:
- widget.styles.display = 'none'
+ # Textual infers StringEnumProperty from the default ("block"),
+ # so ty treats the valid "none" value as an invalid assignment.
+ widget.styles.display = 'none' # ty: ignore[invalid-assignment]
def _populate_patch_list(self) -> None:
"""Populate or refresh the patch list widget."""
diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py
index 69c2769..9cdfa1f 100644
--- a/src/b4/review_tui/_tracking_app.py
+++ b/src/b4/review_tui/_tracking_app.py
@@ -51,6 +51,7 @@ from b4.review_tui._modals import (
ActionScreen,
ArchiveConfirmScreen,
BaseSelectionScreen,
+ CheckLoadingScreen,
CherryPickScreen,
HelpScreen,
LimitScreen,
@@ -606,7 +607,7 @@ class TrackingApp(CheckRunnerMixin, App[Optional[str]]):
self._last_snooze_source: str = ''
self._last_snooze_input: str = ''
# CI check modal state
- self._check_loading: Optional[Any] = None
+ self._check_loading: Optional[CheckLoadingScreen] = None
# Thanks queue count
self._queue_count: int = 0
# Show target branch binding only when configured
diff --git a/src/b4/tui/_common.py b/src/b4/tui/_common.py
index 8eb6c45..306484e 100644
--- a/src/b4/tui/_common.py
+++ b/src/b4/tui/_common.py
@@ -12,7 +12,7 @@ import subprocess
import tempfile
import unicodedata
from collections import defaultdict
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, List, Optional, Protocol
from textual.app import ComposeResult
from textual.binding import Binding
@@ -272,6 +272,12 @@ def _validate_addrs(text: str) -> Optional[str]:
return None
+class _ListViewHost(Protocol):
+ _list_id: str
+
+ def query_one(self, selector: str, expect_type: type[ListView]) -> ListView: ...
+
+
class JKListNavMixin:
"""Mixin providing j/k cursor navigation for a named ListView.
@@ -279,15 +285,13 @@ class JKListNavMixin:
target :class:`ListView` (e.g. ``'#action-list'``).
"""
- _list_id: str = ''
-
- def action_cursor_down(self) -> None:
- lv = self.query_one(self._list_id, ListView) # type: ignore[attr-defined]
+ def action_cursor_down(self: _ListViewHost) -> None:
+ lv = self.query_one(self._list_id, ListView)
if lv.index is not None and lv.index < len(lv.children) - 1:
lv.index += 1
- def action_cursor_up(self) -> None:
- lv = self.query_one(self._list_id, ListView) # type: ignore[attr-defined]
+ def action_cursor_up(self: _ListViewHost) -> None:
+ lv = self.query_one(self._list_id, ListView)
if lv.index is not None and lv.index > 0:
lv.index -= 1
diff --git a/src/b4/ty.py b/src/b4/ty.py
index 0d8adc2..f88b53f 100644
--- a/src/b4/ty.py
+++ b/src/b4/ty.py
@@ -523,7 +523,10 @@ def list_tracked() -> List[JsonDictT]:
# find all tracked bits
tracked = list()
datadir = b4.get_data_dir()
- paths = sorted(Path(datadir).iterdir(), key=os.path.getmtime)
+ # Work around https://github.com/astral-sh/ty/issues/2799, which widens the
+ # sorted element type when the key function accepts a broader path-like type
+ # than Path.
+ paths = sorted(Path(datadir).iterdir(), key=lambda path: path.stat().st_mtime)
for fullpath in paths:
if fullpath.suffix not in ('.pr', '.am'):
continue
diff --git a/src/tests/test___init__.py b/src/tests/test___init__.py
index cf1a699..d4d4eaf 100644
--- a/src/tests/test___init__.py
+++ b/src/tests/test___init__.py
@@ -1,5 +1,6 @@
import email
import email.message
+import email.parser
import email.policy
import email.utils
import io
@@ -40,13 +41,10 @@ def test_save_git_am_mbox(sampledir: Optional[str], tmp_path: pathlib.Path, sour
if ismbox:
msgs = b4.get_msgs_from_mailbox_or_maildir(f'{sampledir}/{source}.txt')
else:
- import email
- import email.parser
with open(f'{sampledir}/{source}.txt', 'rb') as fh:
msg = email.parser.BytesParser(policy=b4.emlpolicy, _class=email.message.EmailMessage).parse(fh)
msgs = [msg]
else:
- import email.message
msgs = list()
for x in range(0, 3):
msg = email.message.EmailMessage()
diff --git a/src/tests/test_tui_tracking.py b/src/tests/test_tui_tracking.py
index 103a7c3..7973b62 100644
--- a/src/tests/test_tui_tracking.py
+++ b/src/tests/test_tui_tracking.py
@@ -1243,7 +1243,8 @@ class TestTrackingDetailPanel:
from textual.containers import Vertical
panel = app.query_one('#details-panel', Vertical)
- assert panel.styles.height.value == 0 # type: ignore[union-attr]
+ assert panel.styles.height is not None
+ assert panel.styles.height.value == 0
@pytest.mark.asyncio
async def test_detail_panel_updates_on_navigation(self, tmp_path: pathlib.Path) -> None:
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 11/12] Enable pyright strict mode
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (9 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 10/12] Add ty and configuration Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 12/12] Add local CI review check Tamir Duberstein
2026-04-10 15:05 ` [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
This catches a few impossible type assertions.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
misc/review-ci-example.py | 6 +++---
pyproject.toml | 15 +++++++++++++--
src/b4/__init__.py | 11 +++++++----
src/b4/ez.py | 29 +++++++++++++++--------------
src/b4/review/tracking.py | 16 +++++++++-------
src/b4/ty.py | 6 +++---
6 files changed, 50 insertions(+), 33 deletions(-)
diff --git a/misc/review-ci-example.py b/misc/review-ci-example.py
index cbac2ae..13a670e 100755
--- a/misc/review-ci-example.py
+++ b/misc/review-ci-example.py
@@ -43,7 +43,7 @@ import sys
def main() -> None:
msg = email.message_from_binary_file(sys.stdin.buffer)
- subject = msg.get('subject', '(no subject)') # noqa: F841
+ subject = msg.get('subject', '(no subject)') # noqa: F841 # pyright: ignore[reportUnusedVariable]
msgid = msg.get('message-id', '').strip('<> ')
# Example: read tracking data for commit-based CI lookups
@@ -51,9 +51,9 @@ def main() -> None:
if tracking_file:
with open(tracking_file) as fp:
tracking = json.load(fp)
- branch_tips = tracking.get('series', {}).get('branch-tips', [])
+ branch_tips = tracking.get('series', {}).get('branch-tips', []) # pyright: ignore[reportUnusedVariable]
else:
- branch_tips = [] # noqa: F841
+ branch_tips = [] # noqa: F841 # pyright: ignore[reportUnusedVariable]
# Seed the RNG with the message-id so results are stable across
# repeated runs of the same message (simulates cached CI results).
diff --git a/pyproject.toml b/pyproject.toml
index 25338e7..b89c4d9 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -125,8 +125,19 @@ strict = true
warn_unreachable = true
[tool.pyright]
-typeCheckingMode = "standard"
-reportUnusedImport = true
+typeCheckingMode = "strict"
+
+# Overly strict rules.
+reportConstantRedefinition = false
+reportUnknownArgumentType = false
+reportUnknownLambdaType = false
+reportUnknownMemberType = false
+reportUnknownVariableType = false
+
+# False positives caused by underscore-prefixed functions used across files.
+# Might be good to clean this up.
+reportPrivateUsage = false
+reportUnusedFunction = false
[tool.ty.rules]
all = "error"
diff --git a/src/b4/__init__.py b/src/b4/__init__.py
index c9b05d8..1711758 100644
--- a/src/b4/__init__.py
+++ b/src/b4/__init__.py
@@ -61,7 +61,9 @@ from email.message import EmailMessage, Message
charset.add_charset('utf-8', None)
# Policy we use for saving mail locally
-emlpolicy = email.policy.EmailPolicy(utf8=True, cte_type='8bit', max_line_length=None, message_factory=EmailMessage)
+emlpolicy: email.policy.EmailPolicy[EmailMessage] = email.policy.EmailPolicy(
+ utf8=True, cte_type='8bit', max_line_length=None, message_factory=EmailMessage
+)
# Presence of these characters requires quoting of the name in the header
# adapted from email._parseaddr
@@ -4086,7 +4088,9 @@ def git_range_to_patches(gitdir: Optional[str], start: str, end: str,
msg.set_charset('utf-8')
# Clean From to remove any 7bit-safe encoding
origfrom = LoreMessage.clean_header(msg.get('From'))
- lsubject = LoreSubject(msg.get('Subject'), presubject=presubject)
+ lsubject = LoreSubject(
+ LoreMessage.clean_header(msg.get('Subject')), presubject=presubject
+ )
lsubject.counter = counter + 1
lsubject.expected = expected
if revision is not None:
@@ -5378,8 +5382,7 @@ def get_git_bool(gitbool: str) -> bool:
def mailbox_email_factory(fh: BinaryIO) -> EmailMessage:
"""Factory function to create EmailMessage objects"""
- msg = email.parser.BytesParser(policy=emlpolicy, _class=EmailMessage).parse(fh) # type: EmailMessage
- return msg
+ return email.parser.BytesParser(policy=emlpolicy, _class=EmailMessage).parse(fh)
def get_msgs_from_mailbox_or_maildir(mbmd: str) -> List[EmailMessage]:
diff --git a/src/b4/ez.py b/src/b4/ez.py
index 9f55bc9..4e38c38 100644
--- a/src/b4/ez.py
+++ b/src/b4/ez.py
@@ -2346,20 +2346,21 @@ def cmd_send(cmdargs: argparse.Namespace) -> None:
def get_sent_tagname(tagbase: str, tagprefix: str, revstr: Union[str, int]) -> Tuple[str, Optional[int]]:
revision = None
- if isinstance(revstr, int):
- revision = revstr
- elif isinstance(revstr, str):
- try:
- revision = int(revstr)
- except ValueError:
- matches = re.search(r'^v(\d+)$', revstr)
- if not matches:
- # assume we got a full tag name, so try to find the revision there
- matches = re.search(r'v(\d+)$', revstr)
- if matches:
- revision = int(matches.groups()[0])
- return revstr.replace('refs/tags/', ''), revision
- revision = int(matches.groups()[0])
+ match revstr:
+ case int():
+ revision = revstr
+ case str():
+ try:
+ revision = int(revstr)
+ except ValueError:
+ matches = re.search(r'^v(\d+)$', revstr)
+ if not matches:
+ # assume we got a full tag name, so try to find the revision there
+ matches = re.search(r'v(\d+)$', revstr)
+ if matches:
+ revision = int(matches.groups()[0])
+ return revstr.replace('refs/tags/', ''), revision
+ revision = int(matches.groups()[0])
if tagbase.startswith('b4/'):
return f'{tagprefix}{tagbase[3:]}-v{revision}', revision
diff --git a/src/b4/review/tracking.py b/src/b4/review/tracking.py
index 9c5d33f..16e62cb 100644
--- a/src/b4/review/tracking.py
+++ b/src/b4/review/tracking.py
@@ -933,13 +933,15 @@ def get_review_target_branches() -> list[str]:
"""Return all configured review-target-branch values."""
config = b4.get_main_config()
val = config.get('review-target-branch')
- if val is None:
- return []
- if isinstance(val, list):
- return [str(v) for v in val if v]
- if isinstance(val, str) and val:
- return [val]
- return []
+ match val:
+ case None:
+ return []
+ case list():
+ return [v for v in val if v]
+ case str():
+ if val:
+ return [val]
+ return []
def get_review_target_branch_default() -> Optional[str]:
diff --git a/src/b4/ty.py b/src/b4/ty.py
index f88b53f..4ef9c33 100644
--- a/src/b4/ty.py
+++ b/src/b4/ty.py
@@ -469,8 +469,8 @@ def send_messages(listing: List[JsonDictT], branch: str, cmdargs: argparse.Names
outgoing += 1
if send_email:
- if not fromaddr and isinstance(jsondata['myemail'], str):
- fromaddr = jsondata['myemail']
+ if not fromaddr:
+ fromaddr = user_email
logger.info(' Sending: %s', b4.LoreMessage.clean_header(msg.get('subject')))
b4.send_mail(smtp, [msg], fromaddr, dryrun=cmdargs.dryrun)
else:
@@ -878,7 +878,7 @@ def get_branch_info(gitdir: Optional[str], branch: str) -> Dict[str, str]:
BRANCH_INFO = dict()
remotecfg = b4.get_config_from_git('branch\\.%s\\..*' % branch)
- if remotecfg is None or 'remote' not in remotecfg:
+ if 'remote' not in remotecfg:
# Did not find a matching branch entry, so look at remotes
gitargs = ['remote', 'show']
lines = b4.git_get_command_lines(gitdir, gitargs)
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* [PATCH b4 12/12] Add local CI review check
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (10 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 11/12] Enable pyright strict mode Tamir Duberstein
@ 2026-04-07 16:48 ` Tamir Duberstein
2026-04-10 15:05 ` [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
12 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-07 16:48 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev, Tamir Duberstein
Configure b4's review TUI to run the project's local checks as a
per-series check command. The helper emits the JSON shape expected by b4
and records the input message headers so results show which series was
checked.
Signed-off-by: Tamir Duberstein <tamird@kernel.org>
---
.b4-config | 1 +
misc/b4-ci.py | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 58 insertions(+)
diff --git a/.b4-config b/.b4-config
index ea95e4a..9fcebab 100644
--- a/.b4-config
+++ b/.b4-config
@@ -5,3 +5,4 @@
send-series-cc = Konstantin Ryabitsev <konstantin@linuxfoundation.org>
send-endpoint-web = https://lkml.kernel.org/_b4_submit
send-prefixes = b4
+ review-series-check-cmd = uv run --all-extras completion --all-groups python misc/b4-ci.py
diff --git a/misc/b4-ci.py b/misc/b4-ci.py
new file mode 100644
index 0000000..c65863c
--- /dev/null
+++ b/misc/b4-ci.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-or-later
+"""Run b4's local CI checks for the review TUI."""
+
+import json
+import subprocess
+import sys
+from email.parser import BytesParser
+from email.policy import default
+from typing import Dict, List
+
+CHECKS = [
+ ('ruff', ['ruff', 'check', '.']),
+ ('mypy', ['mypy', '.']),
+ ('pyright', ['pyright', '.']),
+ ('ty', ['ty', 'check', '.']),
+ ('pytest', ['pytest', '--durations=20']),
+]
+
+
+def read_input_details() -> List[str]:
+ msg = BytesParser(policy=default).parse(sys.stdin.buffer, headersonly=True)
+ details: List[str] = []
+ if subject := msg.get('Subject'):
+ details.append(f'Input-Subject: {subject}')
+ if msgid := msg.get('Message-ID'):
+ details.append(f'Input-Message-ID: {msgid}')
+ return details
+
+
+def run_check(tool: str, cmd: List[str], input_details: List[str]) -> Dict[str, str]:
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False)
+ details = [f'$ {" ".join(cmd)}']
+ if input_details:
+ details.extend(['', *input_details])
+ if proc.stdout:
+ details.extend(['', proc.stdout.rstrip()])
+ if proc.stderr:
+ details.extend(['', proc.stderr.rstrip()])
+
+ return {
+ 'tool': tool,
+ 'status': 'pass' if proc.returncode == 0 else 'fail',
+ 'summary': 'passed' if proc.returncode == 0 else f'failed with exit code {proc.returncode}',
+ 'details': '\n'.join(details),
+ }
+
+
+def main() -> None:
+ input_details = read_input_details()
+ results = [run_check(tool, cmd, input_details) for tool, cmd in CHECKS]
+ json.dump(results, sys.stdout, indent=2)
+ sys.stdout.write('\n')
+
+
+if __name__ == '__main__':
+ main()
--
2.53.0
^ permalink raw reply related [flat|nested] 16+ messages in thread* Re: [PATCH b4 00/12] Enable stricter local checks
2026-04-07 16:48 [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
` (11 preceding siblings ...)
2026-04-07 16:48 ` [PATCH b4 12/12] Add local CI review check Tamir Duberstein
@ 2026-04-10 15:05 ` Tamir Duberstein
2026-04-10 15:21 ` Konstantin Ryabitsev
12 siblings, 1 reply; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-10 15:05 UTC (permalink / raw)
To: Kernel.org Tools; +Cc: Konstantin Ryabitsev
On Tue, Apr 7, 2026 at 12:48 PM Tamir Duberstein <tamird@kernel.org> wrote:
>
> This series makes b4 local developer checks enforceable from the
> review TUI and makes the repo clean under ruff, mypy, pyright, and ty.
>
> The early patches set ruff formatting and import behavior, make the
> test environment reproducible under uv, and type the misc helpers enough
> for whole-repo mypy. The middle patches tighten mypy and pyright, then
> add ty with all rules enabled and bump the Python requirement to 3.11
> because the code already uses 3.11-only syntax.
>
> The last patch wires the review TUI to run the same local checks through
> a JSON-emitting series check helper, so reviewers can see ruff, mypy,
> pyright, ty, and pytest results from b4 itself.
>
> Signed-off-by: Tamir Duberstein <tamird@kernel.org>
Hi Konstantin, any interest in these changes? I see some of b4 has
been moved to liblore, but I believe that package has the same type
checking gaps. No worries if you're not interested in these patches,
but I wanted to demonstrate that mypy (even in strict mode) is still
quite lax (e.g. it doesn't warn on unreachable code).
Cheers.
Tamir
^ permalink raw reply [flat|nested] 16+ messages in thread* Re: [PATCH b4 00/12] Enable stricter local checks
2026-04-10 15:05 ` [PATCH b4 00/12] Enable stricter local checks Tamir Duberstein
@ 2026-04-10 15:21 ` Konstantin Ryabitsev
2026-04-10 22:39 ` Tamir Duberstein
0 siblings, 1 reply; 16+ messages in thread
From: Konstantin Ryabitsev @ 2026-04-10 15:21 UTC (permalink / raw)
To: Tamir Duberstein; +Cc: Kernel.org Tools
On Fri, Apr 10, 2026 at 11:05:31AM -0400, Tamir Duberstein wrote:
> > The last patch wires the review TUI to run the same local checks through
> > a JSON-emitting series check helper, so reviewers can see ruff, mypy,
> > pyright, ty, and pytest results from b4 itself.
> >
> > Signed-off-by: Tamir Duberstein <tamird@kernel.org>
>
> Hi Konstantin, any interest in these changes? I see some of b4 has
> been moved to liblore, but I believe that package has the same type
> checking gaps. No worries if you're not interested in these patches,
> but I wanted to demonstrate that mypy (even in strict mode) is still
> quite lax (e.g. it doesn't warn on unreachable code).
No, I do want to see them -- it's just with the latest changes in master the
series no longer applies and it got pushed down in priority. If you want to
target liblore first, that will be an easier target for me to take and then we
can revisit b4 linting changes.
--
KR
^ permalink raw reply [flat|nested] 16+ messages in thread
* Re: [PATCH b4 00/12] Enable stricter local checks
2026-04-10 15:21 ` Konstantin Ryabitsev
@ 2026-04-10 22:39 ` Tamir Duberstein
0 siblings, 0 replies; 16+ messages in thread
From: Tamir Duberstein @ 2026-04-10 22:39 UTC (permalink / raw)
To: Konstantin Ryabitsev; +Cc: Kernel.org Tools
On Fri, Apr 10, 2026 at 11:21 AM Konstantin Ryabitsev
<konstantin@linuxfoundation.org> wrote:
>
> On Fri, Apr 10, 2026 at 11:05:31AM -0400, Tamir Duberstein wrote:
> > > The last patch wires the review TUI to run the same local checks through
> > > a JSON-emitting series check helper, so reviewers can see ruff, mypy,
> > > pyright, ty, and pytest results from b4 itself.
> > >
> > > Signed-off-by: Tamir Duberstein <tamird@kernel.org>
> >
> > Hi Konstantin, any interest in these changes? I see some of b4 has
> > been moved to liblore, but I believe that package has the same type
> > checking gaps. No worries if you're not interested in these patches,
> > but I wanted to demonstrate that mypy (even in strict mode) is still
> > quite lax (e.g. it doesn't warn on unreachable code).
>
> No, I do want to see them -- it's just with the latest changes in master the
> series no longer applies and it got pushed down in priority. If you want to
> target liblore first, that will be an easier target for me to take and then we
> can revisit b4 linting changes.
Good to hear. The liblore series is
https://lore.kernel.org/all/20260410-harden-type-checking-v1-0-fcf314d9d748@gmail.com/.
Cheers.
Tamir
^ permalink raw reply [flat|nested] 16+ messages in thread