enable mypy type checking & fixup some code

Seems to be fast enough for this codebase, especially once cached.

Change-Id: Id60f4c5fa7971e818f879fbe2548bd501e22e15e
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repohooks/+/574161
Reviewed-by: Raul Rangel <rrangel@google.com>
Tested-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Mike Frysinger <vapier@google.com>
diff --git a/pre-upload.py b/pre-upload.py
index cb0cf15..5c566c6 100755
--- a/pre-upload.py
+++ b/pre-upload.py
@@ -26,7 +26,7 @@
 from pathlib import Path
 import signal
 import sys
-from typing import List, Optional
+from typing import List, Optional, Sequence, Set, Tuple
 
 
 # Assert some minimum Python versions as we don't test or support any others.
@@ -70,16 +70,16 @@
     # How long a hook is allowed to run before we warn that it is "too slow".
     _SLOW_HOOK_DURATION = datetime.timedelta(seconds=30)
 
-    def __init__(self, project_name):
+    def __init__(self, project_name: str) -> None:
         """Create a new Output object for a specified project.
 
         Args:
             project_name: name of project.
         """
         self.project_name = project_name
-        self.hooks = None
-        self.num_hooks = None
-        self.num_commits = None
+        self.hooks: Optional[Set[rh.hooks.CallableHook]] = None
+        self.num_hooks = 0
+        self.num_commits = 0
         self.commit_index = 0
         self.success = True
         self.start_time = datetime.datetime.now()
@@ -96,7 +96,12 @@
         self.num_commits = num_commits
         self.commit_index = 1
 
-    def commit_start(self, hooks, commit, commit_summary):
+    def commit_start(
+        self,
+        hooks: List[rh.hooks.CallableHook],
+        commit: str,
+        commit_summary: str,
+    ) -> None:
         """Emit status for new commit.
 
         Args:
@@ -117,8 +122,9 @@
         self.num_hooks = len(hooks)
         self.hook_banner()
 
-    def hook_banner(self):
+    def hook_banner(self) -> None:
         """Display the banner for current set of hooks."""
+        assert self.hooks is not None, "Must call commit_start() first"
         pending = ", ".join(x.name for x in self.hooks)
         status_line = (
             f"[{self.RUNNING} "
@@ -130,8 +136,13 @@
             status_line = status_line[0 : cols + self._banner_esc_chars]
         rh.terminal.print_status_line(status_line)
 
-    def hook_finish(self, hook, duration):
+    def hook_finish(
+        self,
+        hook: rh.hooks.CallableHook,
+        duration: datetime.timedelta,
+    ) -> None:
         """Finish processing any per-hook state."""
+        assert self.hooks is not None, "Must call commit_start() first"
         self.hooks.remove(hook)
         if duration >= self._SLOW_HOOK_DURATION:
             d = rh.utils.timedelta_str(duration)
@@ -146,7 +157,7 @@
         if self.hooks:
             self.hook_banner()
 
-    def hook_error(self, hook, error):
+    def hook_error(self, hook: rh.hooks.CallableHook, error: str) -> None:
         """Print an error for a single hook.
 
         Args:
@@ -155,7 +166,7 @@
         """
         self.error(f"{hook.name} hook", error)
 
-    def hook_warning(self, hook, warning):
+    def hook_warning(self, hook: rh.hooks.CallableHook, warning: str) -> None:
         """Print a warning for a single hook.
 
         Args:
@@ -185,6 +196,8 @@
     ) -> None:
         """Display summary of possible fixups for a single hook."""
         for result in (x for x in hook_results if x.fixup_cmd):
+            # Workaround mypy unable to peer inside the loop generator above.
+            assert result.fixup_cmd
             cmd = result.fixup_cmd + list(result.files)
             for line in (
                 f"[{self.FIXUP}] {result.hook} has automated fixups available",
@@ -193,7 +206,7 @@
             ):
                 rh.terminal.print_status_line(line, print_newline=True)
 
-    def finish(self):
+    def finish(self) -> None:
         """Print summary for all the hooks."""
         header = self.PASSED if self.success else self.FAILED
         status = "passed" if self.success else "failed"
@@ -244,7 +257,7 @@
     )
 
 
-def _get_project_config(from_git=False):
+def _get_project_config(from_git: bool = False) -> rh.config.PreUploadSettings:
     """Returns the configuration for a project.
 
     Expects to be called from within the project root.
@@ -254,11 +267,11 @@
             be used.
     """
     if from_git:
-        global_paths = (rh.git.find_repo_root(),)
+        global_paths: Sequence[str] = (rh.git.find_repo_root(),)
     else:
         global_paths = (
             # Load the global config found in the manifest repo.
-            (os.path.join(rh.git.find_repo_root(), ".repo", "manifests")),
+            os.path.join(rh.git.find_repo_root(), ".repo", "manifests"),
             # Load the global config found in the root of the repo checkout.
             rh.git.find_repo_root(),
         )
@@ -273,7 +286,7 @@
 def _attempt_fixes(projects_results: List[rh.results.ProjectResults]) -> None:
     """Attempts to fix fixable results."""
     # Filter out any result that has a fixup.
-    fixups = []
+    fixups: List[Tuple[str, rh.results.HookResult]] = []
     for project_results in projects_results:
         fixups.extend(
             (project_results.workdir, x) for x in project_results.fixups
@@ -314,6 +327,10 @@
 
     # Walk all the fixups and run them one-by-one.
     for workdir, result in fixups:
+        # ProjectResults.fixups only yields results that have a fixup command,
+        # but mypy is not able to see that extended logic.
+        assert result.fixup_cmd
+
         if mode == "some":
             if not rh.terminal.boolean_prompt(
                 f"Run {result.hook} fixup for {result.commit}"
@@ -507,7 +524,9 @@
         if not proj_dirs:
             print(f"{project_name} cannot be found.", file=sys.stderr)
             print("Please specify a valid project.", file=sys.stderr)
-            return False
+            return rh.results.ProjectResults(
+                project_name, "", [], internal_failure=True
+            )
         if len(proj_dirs) > 1:
             print(
                 f"{project_name} is associated with multiple directories.",
@@ -517,7 +536,9 @@
                 "Please specify a directory to help disambiguate.",
                 file=sys.stderr,
             )
-            return False
+            return rh.results.ProjectResults(
+                project_name, "", [], internal_failure=True
+            )
         proj_dir = proj_dirs[0]
 
     pwd = os.getcwd()
@@ -608,7 +629,7 @@
         sys.exit(1)
 
 
-def _identify_project(path, from_git=False):
+def _identify_project(path: str, from_git: bool = False) -> str:
     """Identify the repo project associated with the given path.
 
     Returns:
@@ -642,7 +663,7 @@
         return rh.utils.run(cmd, capture_output=True, cwd=path).stdout.strip()
 
 
-def direct_main(argv):
+def direct_main(argv: List[str]) -> int:
     """Run hooks directly (outside of the context of repo).
 
     Args:
diff --git a/rh/config.py b/rh/config.py
index c421485..10686da 100644
--- a/rh/config.py
+++ b/rh/config.py
@@ -14,6 +14,8 @@
 
 """Manage various config files."""
 
+from __future__ import annotations
+
 import configparser
 import functools
 import itertools
@@ -21,6 +23,7 @@
 from pathlib import Path
 import shlex
 import sys
+from typing import Dict, Iterable, Iterator, List, Optional, Sequence
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -98,7 +101,11 @@
     OPTION_IGNORE_MERGED_COMMITS = "ignore_merged_commits"
     VALID_OPTIONS = {OPTION_IGNORE_MERGED_COMMITS}
 
-    def __init__(self, config=None, source=None):
+    def __init__(
+        self,
+        config: Optional[RawConfigParser] = None,
+        source: Optional[str] = None,
+    ) -> None:
         """Initialize.
 
         Args:
@@ -112,26 +119,26 @@
             self._validate()
 
     @property
-    def custom_hooks(self):
+    def custom_hooks(self) -> List[str]:
         """List of custom hooks to run (their keys/names)."""
         return self.config.options(self.CUSTOM_HOOKS_SECTION, [])
 
-    def custom_hook(self, hook):
+    def custom_hook(self, hook: str) -> List[str]:
         """The command to execute for |hook|."""
         return shlex.split(
             self.config.get(self.CUSTOM_HOOKS_SECTION, hook, fallback="")
         )
 
     @property
-    def builtin_hooks(self):
+    def builtin_hooks(self) -> List[str]:
         """List of all enabled builtin hooks (their keys/names)."""
         return [
             k
             for k, v in self.config.items(self.BUILTIN_HOOKS_SECTION, ())
-            if rh.shell.boolean_shell_value(v, None)
+            if rh.shell.boolean_shell_value(v, False)
         ]
 
-    def builtin_hook_option(self, hook):
+    def builtin_hook_option(self, hook: str) -> List[str]:
         """The options to pass to |hook|."""
         return shlex.split(
             self.config.get(
@@ -139,7 +146,7 @@
             )
         )
 
-    def builtin_hook_exclude_paths(self, hook):
+    def builtin_hook_exclude_paths(self, hook: str) -> List[str]:
         """List of paths for which |hook| should not be executed."""
         return shlex.split(
             self.config.get(
@@ -148,11 +155,11 @@
         )
 
     @property
-    def tool_paths(self):
+    def tool_paths(self) -> Dict[str, str]:
         """List of all tool paths."""
         return dict(self.config.items(self.TOOL_PATHS_SECTION, ()))
 
-    def callable_custom_hooks(self):
+    def callable_custom_hooks(self) -> Iterator[rh.hooks.CallableHook]:
         """Yield a CallableHook for each hook to be executed."""
         scope = rh.hooks.ExclusionScope([])
         for hook in self.custom_hooks:
@@ -162,7 +169,7 @@
             func = functools.partial(rh.hooks.check_custom, options=options)
             yield rh.hooks.CallableHook(hook, func, scope)
 
-    def callable_builtin_hooks(self):
+    def callable_builtin_hooks(self) -> Iterator[rh.hooks.CallableHook]:
         """Yield a CallableHook for each hook to be executed."""
         scope = rh.hooks.ExclusionScope([])
         for hook in self.builtin_hooks:
@@ -178,7 +185,7 @@
             yield rh.hooks.CallableHook(hook, func, scope)
 
     @property
-    def ignore_merged_commits(self):
+    def ignore_merged_commits(self) -> bool:
         """Whether to skip hooks for merged commits."""
         return rh.shell.boolean_shell_value(
             self.config.get(
@@ -193,7 +200,7 @@
         """Merge settings from |preupload_config| into ourself."""
         self.config.read_dict(preupload_config.config)
 
-    def _validate(self):
+    def _validate(self) -> None:
         """Run consistency checks on the config settings."""
         config = self.config
 
@@ -282,9 +289,9 @@
         path: The path of the file.
     """
 
-    FILENAME = None
+    FILENAME: str
 
-    def __init__(self, path):
+    def __init__(self, path: str) -> None:
         """Initialize.
 
         Args:
@@ -301,7 +308,7 @@
         self._validate()
 
     @classmethod
-    def from_paths(cls, paths):
+    def from_paths(cls, paths: Iterable[str]) -> Iterator["PreUploadFile"]:
         """Search for files within paths that matches the class FILENAME.
 
         Args:
@@ -321,7 +328,7 @@
 
     FILENAME = "PREUPLOAD.cfg"
 
-    def _validate(self):
+    def _validate(self) -> None:
         super()._validate()
 
         # Reject Exclude Paths section for local config.
@@ -345,7 +352,11 @@
     settings for a particular project.
     """
 
-    def __init__(self, paths=("",), global_paths=()):
+    def __init__(
+        self,
+        paths: Sequence[str] = ("",),
+        global_paths: Sequence[str] = (),
+    ) -> None:
         """Initialize.
 
         All the config files found will be merged together in order.
diff --git a/rh/git.py b/rh/git.py
index f6fde73..072bbb6 100644
--- a/rh/git.py
+++ b/rh/git.py
@@ -18,6 +18,7 @@
 from pathlib import Path
 import re
 import sys
+from typing import List, Optional, Union
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -28,7 +29,7 @@
 import rh.utils
 
 
-def get_upstream_remote():
+def get_upstream_remote() -> str:
     """Returns the current upstream remote name."""
     # First get the current branch name.
     cmd = ["git", "rev-parse", "--abbrev-ref", "HEAD"]
@@ -41,7 +42,7 @@
     return result.stdout.strip()
 
 
-def get_upstream_branch():
+def get_upstream_branch() -> str:
     """Returns the upstream tracking branch of the current branch.
 
     Raises:
@@ -69,14 +70,14 @@
     return full_upstream.replace("heads", "remotes/" + remote)
 
 
-def get_commit_for_ref(ref):
+def get_commit_for_ref(ref: str) -> str:
     """Returns the latest commit for this ref."""
     cmd = ["git", "rev-parse", ref]
     result = rh.utils.run(cmd, capture_output=True)
     return result.stdout.strip()
 
 
-def get_remote_revision(ref, remote):
+def get_remote_revision(ref: str, remote: str) -> str:
     """Returns the remote revision for this ref."""
     prefix = f"refs/remotes/{remote}/"
     if ref.startswith(prefix):
@@ -84,13 +85,13 @@
     return ref
 
 
-def get_patch(commit):
+def get_patch(commit: str) -> str:
     """Returns the patch for this commit."""
     cmd = ["git", "format-patch", "--stdout", "-1", commit]
     return rh.utils.run(cmd, capture_output=True).stdout
 
 
-def get_file_content(commit, path):
+def get_file_content(commit: str, path: str) -> str:
     """Returns the content of a file at a specific commit.
 
     We can't rely on the file as it exists in the filesystem as people might be
@@ -110,16 +111,16 @@
     # pylint: disable=redefined-builtin
     def __init__(
         self,
-        src_mode=0,
-        dst_mode=0,
-        src_sha=None,
-        dst_sha=None,
-        status=None,
-        score=None,
-        src_file=None,
-        dst_file=None,
-        file=None,
-    ):
+        src_mode: Union[str, int] = 0,
+        dst_mode: Union[str, int] = 0,
+        src_sha: Optional[str] = None,
+        dst_sha: Optional[str] = None,
+        status: Optional[str] = None,
+        score: Optional[str] = None,
+        src_file: Optional[str] = None,
+        dst_file: Optional[str] = None,
+        file: Optional[str] = None,
+    ) -> None:
         self.src_mode = src_mode
         self.dst_mode = dst_mode
         self.src_sha = src_sha
@@ -140,7 +141,7 @@
 )
 
 
-def raw_diff(path, target):
+def raw_diff(path: str, target: str) -> List[RawDiffEntry]:
     """Return the parsed raw format diff of target
 
     Args:
@@ -170,7 +171,7 @@
     return entries
 
 
-def get_affected_files(commit):
+def get_affected_files(commit: str) -> List[RawDiffEntry]:
     """Returns list of file paths that were modified/added.
 
     Returns:
@@ -179,7 +180,7 @@
     return raw_diff(os.getcwd(), f"{commit}^-")
 
 
-def get_commits(ignore_merged_commits=False):
+def get_commits(ignore_merged_commits=False) -> List[str]:
     """Returns a list of commits for this review."""
     cmd = ["git", "rev-list", f"{get_upstream_branch()}.."]
     if ignore_merged_commits:
@@ -187,16 +188,17 @@
     return rh.utils.run(cmd, capture_output=True).stdout.split()
 
 
-def get_commit_desc(commit):
+def get_commit_desc(commit: str) -> str:
     """Returns the full commit message of a commit."""
     cmd = ["git", "diff-tree", "-s", "--always", "--format=%B", commit]
     return rh.utils.run(cmd, capture_output=True).stdout
 
 
-def find_repo_root(path=None, outer=False):
+def find_repo_root(path: Optional[str] = None, outer: bool = False) -> str:
     """Locate the top level of this repo checkout starting at |path|.
 
     Args:
+        path: Path under repo checkout to search; defaults to the cwd.
         outer: Whether to find the outermost manifest, or the sub-manifest.
     """
     if path is None:
@@ -235,7 +237,7 @@
     return path
 
 
-def is_git_repository(path):
+def is_git_repository(path: str) -> bool:
     """Returns True if the path is a valid git repository."""
     cmd = ["git", "rev-parse", "--resolve-git-dir", os.path.join(path, ".git")]
     result = rh.utils.run(cmd, capture_output=True, check=False)
diff --git a/rh/results.py b/rh/results.py
index a3b95d2..75a6316 100644
--- a/rh/results.py
+++ b/rh/results.py
@@ -16,27 +16,30 @@
 
 from pathlib import Path
 import sys
-from typing import List, NamedTuple, Optional
+from typing import Iterable, Iterator, List, NamedTuple, Optional
 
 
 THIS_FILE = Path(__file__).resolve()
 THIS_DIR = THIS_FILE.parent
 sys.path.insert(0, str(THIS_DIR.parent))
 
+# pylint: disable=wrong-import-position
+import rh.utils
+
 
 class HookResult(object):
     """A single hook result."""
 
     def __init__(
         self,
-        hook,
-        project,
-        commit,
-        error,
+        hook: str,
+        project: str,
+        commit: str,
+        error: str,
         warning: bool = False,
-        files=(),
+        files: Iterable[str] = (),
         fixup_cmd: Optional[List[str]] = None,
-    ):
+    ) -> None:
         """Initialize.
 
         Args:
@@ -59,11 +62,11 @@
         self.files = files
         self.fixup_cmd = fixup_cmd
 
-    def __bool__(self):
+    def __bool__(self) -> bool:
         """Whether this result is an error."""
         return bool(self.error) and not self.is_warning()
 
-    def is_warning(self):
+    def is_warning(self) -> bool:
         """Whether this result is a non-fatal warning."""
         return self._warning
 
@@ -73,14 +76,14 @@
 
     def __init__(
         self,
-        hook,
-        project,
-        commit,
-        result,
+        hook: str,
+        project: str,
+        commit: str,
+        result: rh.utils.CompletedProcess,
         warning: bool = False,
-        files=(),
+        files: Iterable[str] = (),
         fixup_cmd: Optional[List[str]] = None,
-    ):
+    ) -> None:
         HookResult.__init__(
             self,
             hook,
@@ -93,11 +96,11 @@
         )
         self.result = result
 
-    def __bool__(self):
+    def __bool__(self) -> bool:
         """Whether this result is an error."""
         return not self.is_warning() and self.result.returncode not in (None, 0)
 
-    def is_warning(self):
+    def is_warning(self) -> bool:
         """Whether this result is a non-fatal warning."""
         return self._warning or self.result.returncode == 77
 
@@ -121,12 +124,12 @@
             self.results.extend(results)
 
     @property
-    def fixups(self):
+    def fixups(self) -> Iterator[HookResult]:
         """Yield results that have a fixup available."""
         yield from (
             x for x in self.results if (x or x.is_warning()) and x.fixup_cmd
         )
 
-    def __bool__(self):
+    def __bool__(self) -> bool:
         """Whether there are any errors in this set of results."""
         return self.internal_failure or any(self.results)
diff --git a/rh/shell.py b/rh/shell.py
index 1d51197..cccac74 100644
--- a/rh/shell.py
+++ b/rh/shell.py
@@ -17,6 +17,7 @@
 import pathlib
 from pathlib import Path
 import sys
+from typing import Iterable, Optional, Union
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -39,7 +40,10 @@
 _SHELL_ESCAPE_CHARS = r"\"`$"
 
 
-def quote(s):
+_SHELL_QUOTABLE_T = Union[bytes, str, Path]
+
+
+def quote(s: _SHELL_QUOTABLE_T) -> str:
     """Quote |s| in a way that is safe for use in a shell.
 
     We aim to be safe, but also to produce "nice" output.  That means we don't
@@ -68,7 +72,7 @@
     """
     # If callers pass down bad types, don't blow up.
     if isinstance(s, bytes):
-        s = s.encode("utf-8")
+        s = s.decode("utf-8")
     elif isinstance(s, pathlib.PurePath):
         return str(s)
     elif not isinstance(s, str):
@@ -93,7 +97,7 @@
     return f'"{s}"'
 
 
-def unquote(s):
+def unquote(s: str) -> str:
     """Do the opposite of ShellQuote.
 
     This function assumes that the input is a valid escaped string.
@@ -126,7 +130,7 @@
     return output + s[i] if i < len(s) else output
 
 
-def cmd_to_str(cmd):
+def cmd_to_str(cmd: Iterable[_SHELL_QUOTABLE_T]) -> str:
     """Translate a command list into a space-separated string.
 
     The resulting string should be suitable for logging messages and for
@@ -151,7 +155,7 @@
     return " ".join(quote(arg) for arg in cmd)
 
 
-def boolean_shell_value(sval, default):
+def boolean_shell_value(sval: Optional[str], default: bool) -> bool:
     """See if |sval| is a value users typically consider as boolean."""
     if sval is None:
         return default
diff --git a/rh/signals.py b/rh/signals.py
index a7df902..40ec2ba 100644
--- a/rh/signals.py
+++ b/rh/signals.py
@@ -17,6 +17,7 @@
 from pathlib import Path
 import signal
 import sys
+from typing import Any, Callable, Union
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -24,18 +25,24 @@
 sys.path.insert(0, str(THIS_DIR.parent))
 
 
-def relay_signal(handler, signum, frame):
+def relay_signal(
+    handler: Union[Callable[[int, Any], Any], int, None],
+    signum: int,
+    frame: Any,
+) -> bool:
     """Notify a listener returned from getsignal of receipt of a signal.
 
     Returns:
         True if it was relayed to the target, False otherwise.
         False in particular occurs if the target isn't relayable.
     """
-    if handler in (None, signal.SIG_IGN):
+    if handler is None or handler == signal.SIG_IGN:
         return True
-    if handler == signal.SIG_DFL:
+    elif handler == signal.SIG_DFL:
         # This scenario is a fairly painful to handle fully, thus we just
         # state we couldn't handle it and leave it to client code.
         return False
+    elif isinstance(handler, int):
+        raise ValueError(f"Unknown handler '{handler}'")
     handler(signum, frame)
     return True
diff --git a/rh/terminal.py b/rh/terminal.py
index 105aec6..2c4d574 100644
--- a/rh/terminal.py
+++ b/rh/terminal.py
@@ -20,7 +20,7 @@
 import os
 from pathlib import Path
 import sys
-from typing import List, Optional
+from typing import Iterable, Optional
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -46,7 +46,7 @@
     BOLD_START = "\033[1m"
     RESET = "\033[m"
 
-    def __init__(self, enabled=None):
+    def __init__(self, enabled: Optional[bool] = None) -> None:
         """Create a new Color object, optionally disabling color output.
 
         Args:
@@ -55,7 +55,7 @@
         """
         self._enabled = enabled
 
-    def start(self, color):
+    def start(self, color: int) -> str:
         """Returns a start color code.
 
         Args:
@@ -69,7 +69,7 @@
             return self.COLOR_START % (color + 30)
         return ""
 
-    def stop(self):
+    def stop(self) -> str:
         """Returns a stop color code.
 
         Returns:
@@ -80,7 +80,7 @@
             return self.RESET
         return ""
 
-    def color(self, color, text):
+    def color(self, color: int, text: str) -> str:
         """Returns text with conditionally added color escape sequences.
 
         Args:
@@ -102,7 +102,7 @@
         return start + text + self.RESET
 
     @property
-    def enabled(self):
+    def enabled(self) -> bool:
         """See if the colorization is enabled."""
         if self._enabled is None:
             if "NOCOLOR" in os.environ:
@@ -114,7 +114,7 @@
         return self._enabled
 
 
-def print_status_line(line, print_newline=False):
+def print_status_line(line: str, print_newline: bool = False) -> None:
     """Clears the current terminal line, and prints |line|.
 
     Args:
@@ -134,14 +134,15 @@
 
 def str_prompt(
     prompt: str,
-    choices: List[str],
+    choices: Iterable[str],
     lower: bool = True,
 ) -> Optional[str]:
     """Helper function for processing user input.
 
     Args:
-          prompt: The question to present to the user.
-          lower: Whether to lowercase the response.
+        prompt: The question to present to the user.
+        choices: What choices to provide to the user.
+        lower: Whether to lowercase the response.
 
     Returns:
           The string the user entered, or None if EOF (e.g. Ctrl+D).
@@ -161,12 +162,12 @@
 
 
 def boolean_prompt(
-    prompt="Do you want to continue?",
-    default=True,
-    true_value="yes",
-    false_value="no",
-    prolog=None,
-):
+    prompt: str = "Do you want to continue?",
+    default: bool = True,
+    true_value: str = "yes",
+    false_value: str = "no",
+    prolog: Optional[str] = None,
+) -> bool:
     """Helper function for processing boolean choice prompts.
 
     Args:
diff --git a/rh/utils.py b/rh/utils.py
index fc20301..25a0e75 100644
--- a/rh/utils.py
+++ b/rh/utils.py
@@ -23,6 +23,7 @@
 import sys
 import tempfile
 import time
+from typing import IO, Optional, Sequence, Union
 
 
 THIS_FILE = Path(__file__).resolve()
@@ -255,7 +256,7 @@
 # We use the keyword arg |input| which trips up pylint checks.
 # pylint: disable=redefined-builtin
 def run(
-    cmd,
+    cmd: Sequence[Union[str, Path]],
     redirect_stdout=False,
     redirect_stderr=False,
     cwd=None,
@@ -306,8 +307,8 @@
         redirect_stdout, redirect_stderr = True, True
 
     # Set default for variables.
-    popen_stdout = None
-    popen_stderr = None
+    popen_stdout: Optional[IO[bytes]] = None
+    popen_stderr: Optional[Union[int, IO[bytes]]] = None
     stdin = None
     result = CompletedProcess()
 
@@ -315,7 +316,7 @@
     # a self-explanatory exception will be thrown.
     kill_timeout = float(kill_timeout)
 
-    def _get_tempfile():
+    def _get_tempfile() -> IO[bytes]:
         try:
             return tempfile.TemporaryFile(buffering=0)
         except EnvironmentError as e:
@@ -431,14 +432,12 @@
 
             if popen_stdout:
                 # The linter is confused by how stdout is a file & an int.
-                # pylint: disable=maybe-no-member,no-member
                 popen_stdout.seek(0)
                 result.stdout = popen_stdout.read()
                 popen_stdout.close()
 
-            if popen_stderr and popen_stderr != subprocess.STDOUT:
+            if popen_stderr and not isinstance(popen_stderr, int):
                 # The linter is confused by how stderr is a file & an int.
-                # pylint: disable=maybe-no-member,no-member
                 popen_stderr.seek(0)
                 result.stderr = popen_stderr.read()
                 popen_stderr.close()
diff --git a/run_tests b/run_tests
index 8749c2f..91e59f6 100755
--- a/run_tests
+++ b/run_tests
@@ -67,7 +67,7 @@
         "run_tests",
         "tools/cpplint.py-update",
     ]
-    argv = ["--diff", "--check", ROOT_DIR] + extra_programs
+    argv = ["--diff", "--check", str(ROOT_DIR)] + extra_programs
     log_cmd("black", argv)
     return subprocess.run(
         [sys.executable, "-m", "black"] + argv,
@@ -102,7 +102,7 @@
     ).returncode
 
 
-def run_isort():
+def run_isort() -> int:
     """Returns the exit code from isort."""
     argv = ["--version-number"]
     log_cmd("isort", argv)
@@ -112,7 +112,7 @@
         cwd=ROOT_DIR,
     )
 
-    argv = ["--check", ROOT_DIR]
+    argv = ["--check", str(ROOT_DIR)]
     log_cmd("isort", argv)
     return subprocess.run(
         [sys.executable, "-m", "isort"] + argv,
@@ -121,13 +121,33 @@
     ).returncode
 
 
-def main(argv):
+def run_mypy() -> int:
+    """Returns the exit code from mypy."""
+    argv = ["--version"]
+    log_cmd("mypy", argv)
+    subprocess.run(
+        [sys.executable, "-m", "mypy"] + argv,
+        check=True,
+        cwd=ROOT_DIR,
+    )
+
+    argv = ["rh", "pre-upload.py", "run_tests"]
+    log_cmd("mypy", argv)
+    return subprocess.run(
+        [sys.executable, "-m", "mypy"] + argv,
+        check=False,
+        cwd=ROOT_DIR,
+    ).returncode
+
+
+def main(argv: List[str]) -> int:
     """The main entry."""
     checks = (
-        functools.partial(run_pytest, argv),
+        lambda: run_pytest(argv),
         run_black,
         run_pylint,
         run_isort,
+        run_mypy,
     )
     # Run all the tests all the time to get full feedback.  Don't exit on the
     # first error as that makes it more difficult to iterate in the CQ.
diff --git a/run_tests.vpython3 b/run_tests.vpython3
index dd3f4df..5bc3942 100644
--- a/run_tests.vpython3
+++ b/run_tests.vpython3
@@ -62,7 +62,7 @@
 # Required by black==25.1.0
 wheel: <
   name: "infra/python/wheels/mypy-extensions-py3"
-  version: "version:0.4.3"
+  version: "version:1.0.0"
 >
 
 # Required by black==25.1.0
@@ -128,3 +128,8 @@
   name: "infra/python/wheels/dill-py3"
   version: "version:0.3.7"
 >
+
+wheel: <
+  name: "infra/python/wheels/mypy-py3"
+  version: "version:1.2.0"
+>