public inbox for tools@linux.kernel.org
 help / color / mirror / Atom feed
* [PATCH b4 00/12] Enable stricter local checks
@ 2026-04-07 16:48 Tamir Duberstein
  2026-04-07 16:48 ` [PATCH b4 01/12] Configure ruff format with single quotes Tamir Duberstein
                   ` (12 more replies)
  0 siblings, 13 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 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>
---
Tamir Duberstein (12):
      Configure ruff format with single quotes
      Fix ruff check warnings
      Use ruff to sort imports
      Import dependencies unconditionally
      Fix tests under uv with complex git config
      Fix typings in misc/
      Enable mypy unreachable warnings
      Enable and fix pyright diagnostics
      Avoid duplicate map lookups
      Add ty and configuration
      Enable pyright strict mode
      Add local CI review check

 .b4-config                         |   1 +
 misc/b4-ci.py                      |  57 ++++++++
 misc/retrieve_lore_thread.py       |  15 +-
 misc/review-ci-example.py          |   6 +-
 misc/send-receive.py               | 246 ++++++++++++++++++++-----------
 pyproject.toml                     |  42 +++++-
 src/b4/__init__.py                 | 293 +++++++++++++++++++------------------
 src/b4/bugs/__init__.py            |   8 +-
 src/b4/bugs/_import.py             |   3 +-
 src/b4/bugs/_tui.py                |   9 +-
 src/b4/command.py                  |  17 ++-
 src/b4/diff.py                     |  16 +-
 src/b4/dig.py                      |  17 +--
 src/b4/ez.py                       | 110 ++++++--------
 src/b4/kr.py                       |   2 +-
 src/b4/mbox.py                     |  35 ++---
 src/b4/pr.py                       |  30 ++--
 src/b4/review/__init__.py          |  29 ++--
 src/b4/review/_review.py           |  15 +-
 src/b4/review/checks.py            |  17 ++-
 src/b4/review/messages.py          |   1 -
 src/b4/review/tracking.py          |  24 +--
 src/b4/review_tui/__init__.py      |  20 ++-
 src/b4/review_tui/_common.py       | 157 ++++++++++++++++----
 src/b4/review_tui/_entry.py        |   3 +-
 src/b4/review_tui/_lite_app.py     |  19 +--
 src/b4/review_tui/_modals.py       |  61 ++++----
 src/b4/review_tui/_pw_app.py       |  31 ++--
 src/b4/review_tui/_review_app.py   |  90 +++++++-----
 src/b4/review_tui/_tracking_app.py |  83 ++++++-----
 src/b4/tui/_common.py              |  24 +--
 src/b4/tui/_modals.py              |  11 +-
 src/b4/ty.py                       |  46 +++---
 src/tests/conftest.py              |  47 ++++--
 src/tests/test___init__.py         |  15 +-
 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  |   9 +-
 src/tests/test_three_way_merge.py  |   7 +-
 src/tests/test_tui_bugs.py         |   1 -
 src/tests/test_tui_modals.py       |  18 ++-
 src/tests/test_tui_review.py       |   6 +-
 src/tests/test_tui_tracking.py     |  28 ++--
 48 files changed, 1036 insertions(+), 672 deletions(-)
---
base-commit: 05a91672afb00b3b3b5f76c7757a518d80509c59
change-id: 20260403-ruff-check-79f9f5441956

Best regards,
--  
Tamir Duberstein <tamird@kernel.org>


^ permalink raw reply	[flat|nested] 16+ messages in thread

* [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

end of thread, other threads:[~2026-04-10 22:39 UTC | newest]

Thread overview: 16+ messages (download: mbox.gz follow: Atom feed
-- links below jump to the message on this page --
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 ` [PATCH b4 03/12] Use ruff to sort imports Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 04/12] Import dependencies unconditionally Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 05/12] Fix tests under uv with complex git config Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 06/12] Fix typings in misc/ Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 07/12] Enable mypy unreachable warnings Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 08/12] Enable and fix pyright diagnostics Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 09/12] Avoid duplicate map lookups Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 10/12] Add ty and configuration Tamir Duberstein
2026-04-07 16:48 ` [PATCH b4 11/12] Enable pyright strict mode 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
2026-04-10 15:21   ` Konstantin Ryabitsev
2026-04-10 22:39     ` Tamir Duberstein

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox