public inbox for tools@linux.kernel.org
 help / color / mirror / Atom feed
From: Tamir Duberstein <tamird@kernel.org>
To: "Kernel.org Tools" <tools@kernel.org>
Cc: Konstantin Ryabitsev <konstantin@linuxfoundation.org>,
	 Tamir Duberstein <tamird@kernel.org>
Subject: [PATCH b4 10/12] Add ty and configuration
Date: Tue, 07 Apr 2026 12:48:39 -0400	[thread overview]
Message-ID: <20260407-ruff-check-v1-10-c9568541ff67@kernel.org> (raw)
In-Reply-To: <20260407-ruff-check-v1-0-c9568541ff67@kernel.org>

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


  parent reply	other threads:[~2026-04-07 16:48 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
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 ` Tamir Duberstein [this message]
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

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260407-ruff-check-v1-10-c9568541ff67@kernel.org \
    --to=tamird@kernel.org \
    --cc=konstantin@linuxfoundation.org \
    --cc=tools@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox