cleanup: Update codebase to expect Python 3.6

- Bump minimum version to Python 3.6.
- Use f-strings in a lot of places.

Change-Id: I2aa70197230fcec2eff8e7c8eb754f20c08075bb
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/389034
Tested-by: Jason R. Coombs <jaraco@google.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Commit-Queue: Jason R. Coombs <jaraco@google.com>
diff --git a/color.py b/color.py
index e3e2a5f..7751715 100644
--- a/color.py
+++ b/color.py
@@ -194,7 +194,7 @@
         if not opt:
             return _Color(fg, bg, attr)
 
-        v = self._config.GetString("%s.%s" % (self._section, opt))
+        v = self._config.GetString(f"{self._section}.{opt}")
         if v is None:
             return _Color(fg, bg, attr)
 
diff --git a/editor.py b/editor.py
index 10ff158..359cff9 100644
--- a/editor.py
+++ b/editor.py
@@ -104,9 +104,7 @@
             try:
                 rc = subprocess.Popen(args, shell=shell).wait()
             except OSError as e:
-                raise EditorError(
-                    "editor failed, %s: %s %s" % (str(e), editor, path)
-                )
+                raise EditorError(f"editor failed, {str(e)}: {editor} {path}")
             if rc != 0:
                 raise EditorError(
                     "editor failed with exit status %d: %s %s"
diff --git a/git_command.py b/git_command.py
index 0e25639..3c3869a 100644
--- a/git_command.py
+++ b/git_command.py
@@ -196,12 +196,10 @@
     def git(self):
         """The UA when running git."""
         if self._git_ua is None:
-            self._git_ua = "git/%s (%s) git-repo/%s" % (
-                git.version_tuple().full,
-                self.os,
-                RepoSourceVersion(),
+            self._git_ua = (
+                f"git/{git.version_tuple().full} ({self.os}) "
+                f"git-repo/{RepoSourceVersion()}"
             )
-
         return self._git_ua
 
 
@@ -216,7 +214,7 @@
         need = ".".join(map(str, min_version))
         if msg:
             msg = " for " + msg
-        error_msg = "fatal: git %s or later required%s" % (need, msg)
+        error_msg = f"fatal: git {need} or later required{msg}"
         logger.error(error_msg)
         raise GitRequireError(error_msg)
     return False
@@ -243,7 +241,7 @@
         env["GIT_SSH"] = ssh_proxy.proxy
         env["GIT_SSH_VARIANT"] = "ssh"
     if "http_proxy" in env and "darwin" == sys.platform:
-        s = "'http.proxy=%s'" % (env["http_proxy"],)
+        s = f"'http.proxy={env['http_proxy']}'"
         p = env.get("GIT_CONFIG_PARAMETERS")
         if p is not None:
             s = p + " " + s
@@ -468,7 +466,7 @@
                 )
             except Exception as e:
                 raise GitPopenCommandError(
-                    message="%s: %s" % (command[1], e),
+                    message=f"{command[1]}: {e}",
                     project=self.project.name if self.project else None,
                     command_args=self.cmdv,
                 )
diff --git a/git_config.py b/git_config.py
index 6aa8d85..68016ff 100644
--- a/git_config.py
+++ b/git_config.py
@@ -418,7 +418,7 @@
         if p.Wait() == 0:
             return p.stdout
         else:
-            raise GitError("git config %s: %s" % (str(args), p.stderr))
+            raise GitError(f"git config {str(args)}: {p.stderr}")
 
 
 class RepoConfig(GitConfig):
@@ -651,13 +651,11 @@
                             userEmail, host, port
                         )
                 except urllib.error.HTTPError as e:
-                    raise UploadError("%s: %s" % (self.review, str(e)))
+                    raise UploadError(f"{self.review}: {str(e)}")
                 except urllib.error.URLError as e:
-                    raise UploadError("%s: %s" % (self.review, str(e)))
+                    raise UploadError(f"{self.review}: {str(e)}")
                 except http.client.HTTPException as e:
-                    raise UploadError(
-                        "%s: %s" % (self.review, e.__class__.__name__)
-                    )
+                    raise UploadError(f"{self.review}: {e.__class__.__name__}")
 
                 REVIEW_CACHE[u] = self._review_url
         return self._review_url + self.projectname
@@ -666,7 +664,7 @@
         username = self._config.GetString("review.%s.username" % self.review)
         if username is None:
             username = userEmail.split("@")[0]
-        return "ssh://%s@%s:%s/" % (username, host, port)
+        return f"ssh://{username}@{host}:{port}/"
 
     def ToLocal(self, rev):
         """Convert a remote revision string to something we have locally."""
@@ -715,11 +713,11 @@
         self._Set("fetch", list(map(str, self.fetch)))
 
     def _Set(self, key, value):
-        key = "remote.%s.%s" % (self.name, key)
+        key = f"remote.{self.name}.{key}"
         return self._config.SetString(key, value)
 
     def _Get(self, key, all_keys=False):
-        key = "remote.%s.%s" % (self.name, key)
+        key = f"remote.{self.name}.{key}"
         return self._config.GetString(key, all_keys=all_keys)
 
 
@@ -762,11 +760,11 @@
                     fd.write("\tmerge = %s\n" % self.merge)
 
     def _Set(self, key, value):
-        key = "branch.%s.%s" % (self.name, key)
+        key = f"branch.{self.name}.{key}"
         return self._config.SetString(key, value)
 
     def _Get(self, key, all_keys=False):
-        key = "branch.%s.%s" % (self.name, key)
+        key = f"branch.{self.name}.{key}"
         return self._config.GetString(key, all_keys=all_keys)
 
 
diff --git a/git_trace2_event_log_base.py b/git_trace2_event_log_base.py
index 7b51b75..f542424 100644
--- a/git_trace2_event_log_base.py
+++ b/git_trace2_event_log_base.py
@@ -76,9 +76,8 @@
         # Save both our sid component and the complete sid.
         # We use our sid component (self._sid) as the unique filename prefix and
         # the full sid (self._full_sid) in the log itself.
-        self._sid = "repo-%s-P%08x" % (
-            self.start.strftime("%Y%m%dT%H%M%SZ"),
-            os.getpid(),
+        self._sid = (
+            f"repo-{self.start.strftime('%Y%m%dT%H%M%SZ')}-P{os.getpid():08x}"
         )
 
         if add_init_count:
diff --git a/hooks.py b/hooks.py
index 6ded08b..82bf7e3 100644
--- a/hooks.py
+++ b/hooks.py
@@ -180,7 +180,7 @@
                 abort_if_user_denies was passed to the consturctor.
         """
         hooks_config = self._hooks_project.config
-        git_approval_key = "repo.hooks.%s.%s" % (self._hook_type, subkey)
+        git_approval_key = f"repo.hooks.{self._hook_type}.{subkey}"
 
         # Get the last value that the user approved for this hook; may be None.
         old_val = hooks_config.GetString(git_approval_key)
@@ -193,7 +193,7 @@
             else:
                 # Give the user a reason why we're prompting, since they last
                 # told us to "never ask again".
-                prompt = "WARNING: %s\n\n" % (changed_prompt,)
+                prompt = f"WARNING: {changed_prompt}\n\n"
         else:
             prompt = ""
 
@@ -241,9 +241,8 @@
         return self._CheckForHookApprovalHelper(
             "approvedmanifest",
             self._manifest_url,
-            "Run hook scripts from %s" % (self._manifest_url,),
-            "Manifest URL has changed since %s was allowed."
-            % (self._hook_type,),
+            f"Run hook scripts from {self._manifest_url}",
+            f"Manifest URL has changed since {self._hook_type} was allowed.",
         )
 
     def _CheckForHookApprovalHash(self):
@@ -262,7 +261,7 @@
             "approvedhash",
             self._GetHash(),
             prompt % (self._GetMustVerb(), self._script_fullpath),
-            "Scripts have changed since %s was allowed." % (self._hook_type,),
+            f"Scripts have changed since {self._hook_type} was allowed.",
         )
 
     @staticmethod
diff --git a/main.py b/main.py
index bd8d513..604de76 100755
--- a/main.py
+++ b/main.py
@@ -198,9 +198,8 @@
         if short:
             commands = " ".join(sorted(self.commands))
             wrapped_commands = textwrap.wrap(commands, width=77)
-            print(
-                "Available commands:\n  %s" % ("\n  ".join(wrapped_commands),)
-            )
+            help_commands = "".join(f"\n  {x}" for x in wrapped_commands)
+            print(f"Available commands:{help_commands}")
             print("\nRun `repo help <command>` for command-specific details.")
             print("Bug reports:", Wrapper().BUG_URL)
         else:
@@ -236,7 +235,7 @@
         if name in self.commands:
             return name, []
 
-        key = "alias.%s" % (name,)
+        key = f"alias.{name}"
         alias = RepoConfig.ForRepository(self.repodir).GetString(key)
         if alias is None:
             alias = RepoConfig.ForUser().GetString(key)
diff --git a/manifest_xml.py b/manifest_xml.py
index 97baed5..61b130c 100644
--- a/manifest_xml.py
+++ b/manifest_xml.py
@@ -114,9 +114,7 @@
     try:
         return int(value)
     except ValueError:
-        raise ManifestParseError(
-            'manifest: invalid %s="%s" integer' % (attr, value)
-        )
+        raise ManifestParseError(f'manifest: invalid {attr}="{value}" integer')
 
 
 class _Default:
@@ -810,7 +808,7 @@
                         ret.setdefault(child.nodeName, []).append(element)
                     else:
                         raise ManifestParseError(
-                            'Unhandled element "%s"' % (child.nodeName,)
+                            f'Unhandled element "{child.nodeName}"'
                         )
 
                     append_children(element, child)
@@ -1258,12 +1256,10 @@
         try:
             root = xml.dom.minidom.parse(path)
         except (OSError, xml.parsers.expat.ExpatError) as e:
-            raise ManifestParseError(
-                "error parsing manifest %s: %s" % (path, e)
-            )
+            raise ManifestParseError(f"error parsing manifest {path}: {e}")
 
         if not root or not root.childNodes:
-            raise ManifestParseError("no root node in %s" % (path,))
+            raise ManifestParseError(f"no root node in {path}")
 
         for manifest in root.childNodes:
             if (
@@ -1272,7 +1268,7 @@
             ):
                 break
         else:
-            raise ManifestParseError("no <manifest> in %s" % (path,))
+            raise ManifestParseError(f"no <manifest> in {path}")
 
         nodes = []
         for node in manifest.childNodes:
@@ -1282,7 +1278,7 @@
                     msg = self._CheckLocalPath(name)
                     if msg:
                         raise ManifestInvalidPathError(
-                            '<include> invalid "name": %s: %s' % (name, msg)
+                            f'<include> invalid "name": {name}: {msg}'
                         )
                 include_groups = ""
                 if parent_groups:
@@ -1314,7 +1310,7 @@
                     raise
                 except Exception as e:
                     raise ManifestParseError(
-                        "failed parsing included manifest %s: %s" % (name, e)
+                        f"failed parsing included manifest {name}: {e}"
                     )
             else:
                 if parent_groups and node.nodeName == "project":
@@ -1765,13 +1761,13 @@
                 msg = self._CheckLocalPath(name)
                 if msg:
                     raise ManifestInvalidPathError(
-                        '<submanifest> invalid "name": %s: %s' % (name, msg)
+                        f'<submanifest> invalid "name": {name}: {msg}'
                     )
         else:
             msg = self._CheckLocalPath(path)
             if msg:
                 raise ManifestInvalidPathError(
-                    '<submanifest> invalid "path": %s: %s' % (path, msg)
+                    f'<submanifest> invalid "path": {path}: {msg}'
                 )
 
         submanifest = _XmlSubmanifest(
@@ -1806,7 +1802,7 @@
         msg = self._CheckLocalPath(name, dir_ok=True)
         if msg:
             raise ManifestInvalidPathError(
-                '<project> invalid "name": %s: %s' % (name, msg)
+                f'<project> invalid "name": {name}: {msg}'
             )
         if parent:
             name = self._JoinName(parent.name, name)
@@ -1816,7 +1812,7 @@
             remote = self._default.remote
         if remote is None:
             raise ManifestParseError(
-                "no remote for project %s within %s" % (name, self.manifestFile)
+                f"no remote for project {name} within {self.manifestFile}"
             )
 
         revisionExpr = node.getAttribute("revision") or remote.revision
@@ -1837,7 +1833,7 @@
             msg = self._CheckLocalPath(path, dir_ok=True, cwd_dot_ok=True)
             if msg:
                 raise ManifestInvalidPathError(
-                    '<project> invalid "path": %s: %s' % (path, msg)
+                    f'<project> invalid "path": {path}: {msg}'
                 )
 
         rebase = XmlBool(node, "rebase", True)
@@ -2094,7 +2090,7 @@
         if not cwd_dot_ok or parts != ["."]:
             for part in set(parts):
                 if part in {".", "..", ".git"} or part.startswith(".repo"):
-                    return "bad component: %s" % (part,)
+                    return f"bad component: {part}"
 
         if not dir_ok and resep.match(path[-1]):
             return "dirs not allowed"
@@ -2130,7 +2126,7 @@
         msg = cls._CheckLocalPath(dest)
         if msg:
             raise ManifestInvalidPathError(
-                '<%s> invalid "dest": %s: %s' % (element, dest, msg)
+                f'<{element}> invalid "dest": {dest}: {msg}'
             )
 
         # |src| is the file we read from or path we point to for symlinks.
@@ -2141,7 +2137,7 @@
         )
         if msg:
             raise ManifestInvalidPathError(
-                '<%s> invalid "src": %s: %s' % (element, src, msg)
+                f'<{element}> invalid "src": {src}: {msg}'
             )
 
     def _ParseCopyFile(self, project, node):
@@ -2185,7 +2181,7 @@
         v = self._remotes.get(name)
         if not v:
             raise ManifestParseError(
-                "remote %s not defined in %s" % (name, self.manifestFile)
+                f"remote {name} not defined in {self.manifestFile}"
             )
         return v
 
diff --git a/platform_utils.py b/platform_utils.py
index d720a07..4cf994b 100644
--- a/platform_utils.py
+++ b/platform_utils.py
@@ -57,8 +57,8 @@
     if _winpath_is_valid(path):
         return path
     raise ValueError(
-        'Path "{}" must be a relative path or an absolute '
-        "path starting with a drive letter".format(path)
+        f'Path "{path}" must be a relative path or an absolute '
+        "path starting with a drive letter"
     )
 
 
diff --git a/platform_utils_win32.py b/platform_utils_win32.py
index 80a5263..f10d9d0 100644
--- a/platform_utils_win32.py
+++ b/platform_utils_win32.py
@@ -186,9 +186,7 @@
             error_desc = FormatError(code).strip()
             if code == ERROR_PRIVILEGE_NOT_HELD:
                 raise OSError(errno.EPERM, error_desc, link_name)
-            _raise_winerror(
-                code, 'Error creating symbolic link "{}"'.format(link_name)
-            )
+            _raise_winerror(code, f'Error creating symbolic link "{link_name}"')
 
 
 def islink(path):
@@ -210,7 +208,7 @@
     )
     if reparse_point_handle == INVALID_HANDLE_VALUE:
         _raise_winerror(
-            get_last_error(), 'Error opening symbolic link "{}"'.format(path)
+            get_last_error(), f'Error opening symbolic link "{path}"'
         )
     target_buffer = c_buffer(MAXIMUM_REPARSE_DATA_BUFFER_SIZE)
     n_bytes_returned = DWORD()
@@ -227,7 +225,7 @@
     CloseHandle(reparse_point_handle)
     if not io_result:
         _raise_winerror(
-            get_last_error(), 'Error reading symbolic link "{}"'.format(path)
+            get_last_error(), f'Error reading symbolic link "{path}"'
         )
     rdb = REPARSE_DATA_BUFFER.from_buffer(target_buffer)
     if rdb.ReparseTag == IO_REPARSE_TAG_SYMLINK:
@@ -236,11 +234,11 @@
         return rdb.MountPointReparseBuffer.PrintName
     # Unsupported reparse point type.
     _raise_winerror(
-        ERROR_NOT_SUPPORTED, 'Error reading symbolic link "{}"'.format(path)
+        ERROR_NOT_SUPPORTED, f'Error reading symbolic link "{path}"'
     )
 
 
 def _raise_winerror(code, error_desc):
     win_error_desc = FormatError(code).strip()
-    error_desc = "{0}: {1}".format(error_desc, win_error_desc)
+    error_desc = f"{error_desc}: {win_error_desc}"
     raise WinError(code, error_desc)
diff --git a/progress.py b/progress.py
index 54eb8f9..290265c 100644
--- a/progress.py
+++ b/progress.py
@@ -52,11 +52,11 @@
     uses microsecond resolution.  This makes for noisy output.
     """
     hours, mins, secs = convert_to_hms(total)
-    ret = "%.3fs" % (secs,)
+    ret = f"{secs:.3f}s"
     if mins:
-        ret = "%im%s" % (mins, ret)
+        ret = f"{mins}m{ret}"
     if hours:
-        ret = "%ih%s" % (hours, ret)
+        ret = f"{hours}h{ret}"
     return ret
 
 
diff --git a/project.py b/project.py
index 7b78427..069cc71 100644
--- a/project.py
+++ b/project.py
@@ -365,19 +365,19 @@
     for part in components:
         if part in {".", ".."}:
             raise ManifestInvalidPathError(
-                '%s: "%s" not allowed in paths' % (subpath, part)
+                f'{subpath}: "{part}" not allowed in paths'
             )
 
         path = os.path.join(path, part)
         if platform_utils.islink(path):
             raise ManifestInvalidPathError(
-                "%s: traversing symlinks not allow" % (path,)
+                f"{path}: traversing symlinks not allow"
             )
 
         if os.path.exists(path):
             if not os.path.isfile(path) and not platform_utils.isdir(path):
                 raise ManifestInvalidPathError(
-                    "%s: only regular files & directories allowed" % (path,)
+                    f"{path}: only regular files & directories allowed"
                 )
 
     if skipfinal:
@@ -409,11 +409,11 @@
 
         if platform_utils.isdir(src):
             raise ManifestInvalidPathError(
-                "%s: copying from directory not supported" % (self.src,)
+                f"{self.src}: copying from directory not supported"
             )
         if platform_utils.isdir(dest):
             raise ManifestInvalidPathError(
-                "%s: copying to directory not allowed" % (self.dest,)
+                f"{self.dest}: copying to directory not allowed"
             )
 
         # Copy file if it does not exist or is out of date.
@@ -957,15 +957,11 @@
                 f_status = "-"
 
             if i and i.src_path:
-                line = " %s%s\t%s => %s (%s%%)" % (
-                    i_status,
-                    f_status,
-                    i.src_path,
-                    p,
-                    i.level,
+                line = (
+                    f" {i_status}{f_status}\t{i.src_path} => {p} ({i.level}%)"
                 )
             else:
-                line = " %s%s\t%s" % (i_status, f_status, p)
+                line = f" {i_status}{f_status}\t{p}"
 
             if i and not f:
                 out.added("%s", line)
@@ -1157,7 +1153,7 @@
         if dest_branch.startswith(R_HEADS):
             dest_branch = dest_branch[len(R_HEADS) :]
 
-        ref_spec = "%s:refs/for/%s" % (R_HEADS + branch.name, dest_branch)
+        ref_spec = f"{R_HEADS + branch.name}:refs/for/{dest_branch}"
         opts = []
         if auto_topic:
             opts += ["topic=" + branch.name]
@@ -1182,7 +1178,7 @@
         GitCommand(self, cmd, bare=True, verify_command=True).Wait()
 
         if not dryrun:
-            msg = "posted to %s for %s" % (branch.remote.review, dest_branch)
+            msg = f"posted to {branch.remote.review} for {dest_branch}"
             self.bare_git.UpdateRef(
                 R_PUB + branch.name, R_HEADS + branch.name, message=msg
             )
@@ -1444,7 +1440,7 @@
             return self.bare_git.rev_list(self.revisionExpr, "-1")[0]
         except GitError:
             raise ManifestInvalidRevisionError(
-                "revision %s in %s not found" % (self.revisionExpr, self.name)
+                f"revision {self.revisionExpr} in {self.name} not found"
             )
 
     def GetRevisionId(self, all_refs=None):
@@ -1461,7 +1457,7 @@
             return self.bare_git.rev_parse("--verify", "%s^0" % rev)
         except GitError:
             raise ManifestInvalidRevisionError(
-                "revision %s in %s not found" % (self.revisionExpr, self.name)
+                f"revision {self.revisionExpr} in {self.name} not found"
             )
 
     def SetRevisionId(self, revisionId):
@@ -1773,9 +1769,7 @@
                 raise DeleteDirtyWorktreeError(msg, project=self)
 
         if not quiet:
-            print(
-                "%s: Deleting obsolete checkout." % (self.RelPath(local=False),)
-            )
+            print(f"{self.RelPath(local=False)}: Deleting obsolete checkout.")
 
         # Unlock and delink from the main worktree.  We don't use git's worktree
         # remove because it will recursively delete projects -- we handle that
@@ -1968,7 +1962,7 @@
             # target branch, but otherwise take no other action.
             _lwrite(
                 self.work_git.GetDotgitPath(subpath=HEAD),
-                "ref: %s%s\n" % (R_HEADS, name),
+                f"ref: {R_HEADS}{name}\n",
             )
             return True
 
@@ -2277,7 +2271,7 @@
             self.config.SetString("core.repositoryFormatVersion", str(version))
 
         # Enable the extension!
-        self.config.SetString("extensions.%s" % (key,), value)
+        self.config.SetString(f"extensions.{key}", value)
 
     def ResolveRemoteHead(self, name=None):
         """Find out what the default branch (HEAD) points to.
@@ -2447,7 +2441,7 @@
                 old_packed_lines = []
 
                 for r in sorted(all_refs):
-                    line = "%s %s\n" % (all_refs[r], r)
+                    line = f"{all_refs[r]} {r}\n"
                     tmp_packed_lines.append(line)
                     if r not in tmp:
                         old_packed_lines.append(line)
@@ -2617,7 +2611,7 @@
             # one.
             if not verbose and gitcmd.stdout:
                 print(
-                    "\n%s:\n%s" % (self.name, gitcmd.stdout),
+                    f"\n{self.name}:\n{gitcmd.stdout}",
                     end="",
                     file=output_redir,
                 )
@@ -2752,7 +2746,7 @@
             proc = None
             with Trace("Fetching bundle: %s", " ".join(cmd)):
                 if verbose:
-                    print("%s: Downloading bundle: %s" % (self.name, srcUrl))
+                    print(f"{self.name}: Downloading bundle: {srcUrl}")
                 stdout = None if verbose else subprocess.PIPE
                 stderr = None if verbose else subprocess.STDOUT
                 try:
@@ -2810,7 +2804,7 @@
         if GitCommand(self, cmd).Wait() != 0:
             if self._allrefs:
                 raise GitError(
-                    "%s checkout %s " % (self.name, rev), project=self.name
+                    f"{self.name} checkout {rev} ", project=self.name
                 )
 
     def _CherryPick(self, rev, ffonly=False, record_origin=False):
@@ -2824,7 +2818,7 @@
         if GitCommand(self, cmd).Wait() != 0:
             if self._allrefs:
                 raise GitError(
-                    "%s cherry-pick %s " % (self.name, rev), project=self.name
+                    f"{self.name} cherry-pick {rev} ", project=self.name
                 )
 
     def _LsRemote(self, refs):
@@ -2841,9 +2835,7 @@
         cmd.append("--")
         if GitCommand(self, cmd).Wait() != 0:
             if self._allrefs:
-                raise GitError(
-                    "%s revert %s " % (self.name, rev), project=self.name
-                )
+                raise GitError(f"{self.name} revert {rev} ", project=self.name)
 
     def _ResetHard(self, rev, quiet=True):
         cmd = ["reset", "--hard"]
@@ -2852,7 +2844,7 @@
         cmd.append(rev)
         if GitCommand(self, cmd).Wait() != 0:
             raise GitError(
-                "%s reset --hard %s " % (self.name, rev), project=self.name
+                f"{self.name} reset --hard {rev} ", project=self.name
             )
 
     def _SyncSubmodules(self, quiet=True):
@@ -2871,18 +2863,14 @@
             cmd.extend(["--onto", onto])
         cmd.append(upstream)
         if GitCommand(self, cmd).Wait() != 0:
-            raise GitError(
-                "%s rebase %s " % (self.name, upstream), project=self.name
-            )
+            raise GitError(f"{self.name} rebase {upstream} ", project=self.name)
 
     def _FastForward(self, head, ffonly=False):
         cmd = ["merge", "--no-stat", head]
         if ffonly:
             cmd.append("--ff-only")
         if GitCommand(self, cmd).Wait() != 0:
-            raise GitError(
-                "%s merge %s " % (self.name, head), project=self.name
-            )
+            raise GitError(f"{self.name} merge {head} ", project=self.name)
 
     def _InitGitDir(self, mirror_git=None, force_sync=False, quiet=False):
         init_git_dir = not os.path.exists(self.gitdir)
@@ -3171,8 +3159,9 @@
                         "--force-sync not enabled; cannot overwrite a local "
                         "work tree. If you're comfortable with the "
                         "possibility of losing the work tree's git metadata,"
-                        " use `repo sync --force-sync {0}` to "
-                        "proceed.".format(self.RelPath(local=False)),
+                        " use "
+                        f"`repo sync --force-sync {self.RelPath(local=False)}` "
+                        "to proceed.",
                         project=self.name,
                     )
 
@@ -3686,12 +3675,12 @@
                 config = kwargs.pop("config", None)
                 for k in kwargs:
                     raise TypeError(
-                        "%s() got an unexpected keyword argument %r" % (name, k)
+                        f"{name}() got an unexpected keyword argument {k!r}"
                     )
                 if config is not None:
                     for k, v in config.items():
                         cmdv.append("-c")
-                        cmdv.append("%s=%s" % (k, v))
+                        cmdv.append(f"{k}={v}")
                 cmdv.append(name)
                 cmdv.extend(args)
                 p = GitCommand(
diff --git a/repo b/repo
index a9ae4fa..2738142 100755
--- a/repo
+++ b/repo
@@ -33,7 +33,7 @@
 # bit more flexible with older systems.  See that file for more details on the
 # versions we select.
 MIN_PYTHON_VERSION_SOFT = (3, 6)
-MIN_PYTHON_VERSION_HARD = (3, 5)
+MIN_PYTHON_VERSION_HARD = (3, 6)
 
 
 # Keep basic logic in sync with repo_trace.py.
@@ -96,7 +96,7 @@
         # bridge the gap.  This is the fallback anyways so perf isn't critical.
         min_major, min_minor = MIN_PYTHON_VERSION_SOFT
         for inc in range(0, 10):
-            reexec("python{}.{}".format(min_major, min_minor + inc))
+            reexec(f"python{min_major}.{min_minor + inc}")
 
         # Fallback to older versions if possible.
         for inc in range(
@@ -105,7 +105,7 @@
             # Don't downgrade, and don't reexec ourselves (which would infinite loop).
             if (min_major, min_minor - inc) <= (major, minor):
                 break
-            reexec("python{}.{}".format(min_major, min_minor - inc))
+            reexec(f"python{min_major}.{min_minor - inc}")
 
         # Try the generic Python 3 wrapper, but only if it's new enough.  If it
         # isn't, we want to just give up below and make the user resolve things.
@@ -566,8 +566,7 @@
             return output.decode("utf-8")
         except UnicodeError:
             print(
-                "repo: warning: Invalid UTF-8 output:\ncmd: %r\n%r"
-                % (cmd, output),
+                f"repo: warning: Invalid UTF-8 output:\ncmd: {cmd!r}\n{output}",
                 file=sys.stderr,
             )
             return output.decode("utf-8", "backslashreplace")
@@ -590,20 +589,17 @@
     # If things failed, print useful debugging output.
     if check and ret.returncode:
         print(
-            'repo: error: "%s" failed with exit status %s'
-            % (cmd[0], ret.returncode),
+            f'repo: error: "{cmd[0]}" failed with exit status {ret.returncode}',
             file=sys.stderr,
         )
-        print(
-            "  cwd: %s\n  cmd: %r" % (kwargs.get("cwd", os.getcwd()), cmd),
-            file=sys.stderr,
-        )
+        cwd = kwargs.get("cwd", os.getcwd())
+        print(f"  cwd: {cwd}\n  cmd: {cmd!r}", file=sys.stderr)
 
         def _print_output(name, output):
             if output:
                 print(
-                    "  %s:\n  >> %s"
-                    % (name, "\n  >> ".join(output.splitlines())),
+                    f"  {name}:"
+                    + "".join(f"\n  >> {x}" for x in output.splitlines()),
                     file=sys.stderr,
                 )
 
@@ -719,7 +715,7 @@
     except OSError as e:
         if e.errno != errno.EEXIST:
             print(
-                "fatal: cannot make %s directory: %s" % (repodir, e.strerror),
+                f"fatal: cannot make {repodir} directory: {e.strerror}",
                 file=sys.stderr,
             )
             # Don't raise CloneFailure; that would delete the
@@ -817,7 +813,7 @@
     if ver_act < MIN_GIT_VERSION:
         need = ".".join(map(str, MIN_GIT_VERSION))
         print(
-            "fatal: git %s or later required; found %s" % (need, ver_act.full),
+            f"fatal: git {need} or later required; found {ver_act.full}",
             file=sys.stderr,
         )
         raise CloneFailure()
@@ -836,7 +832,8 @@
     KEY = "GIT_TRACE2_PARENT_SID"
 
     now = datetime.datetime.now(datetime.timezone.utc)
-    value = "repo-%s-P%08x" % (now.strftime("%Y%m%dT%H%M%SZ"), os.getpid())
+    timestamp = now.strftime("%Y%m%dT%H%M%SZ")
+    value = f"repo-{timestamp}-P{os.getpid():08x}"
 
     # If it's already set, then append ourselves.
     if KEY in env:
@@ -880,8 +877,7 @@
     except OSError as e:
         if e.errno != errno.EEXIST:
             print(
-                "fatal: cannot make %s directory: %s"
-                % (home_dot_repo, e.strerror),
+                f"fatal: cannot make {home_dot_repo} directory: {e.strerror}",
                 file=sys.stderr,
             )
             sys.exit(1)
@@ -891,15 +887,15 @@
     except OSError as e:
         if e.errno != errno.EEXIST:
             print(
-                "fatal: cannot make %s directory: %s" % (gpg_dir, e.strerror),
+                f"fatal: cannot make {gpg_dir} directory: {e.strerror}",
                 file=sys.stderr,
             )
             sys.exit(1)
 
     if not quiet:
         print(
-            "repo: Updating release signing keys to keyset ver %s"
-            % (".".join(str(x) for x in KEYRING_VERSION),)
+            "repo: Updating release signing keys to keyset ver "
+            + ".".join(str(x) for x in KEYRING_VERSION),
         )
     # NB: We use --homedir (and cwd below) because some environments (Windows) do
     # not correctly handle full native paths.  We avoid the issue by changing to
@@ -951,7 +947,7 @@
         return None
     else:
         print(
-            "repo: error: git %s failed:\n%s" % (" ".join(cmd), ret.stderr),
+            f"repo: error: git {' '.join(cmd)} failed:\n{ret.stderr}",
             file=sys.stderr,
         )
         raise RunError()
@@ -1064,7 +1060,7 @@
         os.mkdir(cwd)
     except OSError as e:
         print(
-            "fatal: cannot make %s directory: %s" % (cwd, e.strerror),
+            f"fatal: cannot make {cwd} directory: {e.strerror}",
             file=sys.stderr,
         )
         raise CloneFailure()
@@ -1104,7 +1100,7 @@
         ret = run_git(
             "rev-parse",
             "--verify",
-            "%s^{commit}" % (committish,),
+            f"{committish}^{{commit}}",
             cwd=cwd,
             check=False,
         )
@@ -1117,7 +1113,7 @@
         rev = resolve("refs/remotes/origin/%s" % committish)
         if rev is None:
             print(
-                'repo: error: unknown branch "%s"' % (committish,),
+                f'repo: error: unknown branch "{committish}"',
                 file=sys.stderr,
             )
             raise CloneFailure()
@@ -1130,7 +1126,8 @@
         rev = resolve(remote_ref)
         if rev is None:
             print(
-                'repo: error: unknown tag "%s"' % (committish,), file=sys.stderr
+                f'repo: error: unknown tag "{committish}"',
+                file=sys.stderr,
             )
             raise CloneFailure()
         return (remote_ref, rev)
@@ -1138,12 +1135,12 @@
     # See if it's a short branch name.
     rev = resolve("refs/remotes/origin/%s" % committish)
     if rev:
-        return ("refs/heads/%s" % (committish,), rev)
+        return (f"refs/heads/{committish}", rev)
 
     # See if it's a tag.
-    rev = resolve("refs/tags/%s" % committish)
+    rev = resolve(f"refs/tags/{committish}")
     if rev:
-        return ("refs/tags/%s" % (committish,), rev)
+        return (f"refs/tags/{committish}", rev)
 
     # See if it's a commit.
     rev = resolve(committish)
@@ -1152,7 +1149,8 @@
 
     # Give up!
     print(
-        'repo: error: unable to resolve "%s"' % (committish,), file=sys.stderr
+        f'repo: error: unable to resolve "{committish}"',
+        file=sys.stderr,
     )
     raise CloneFailure()
 
@@ -1168,8 +1166,8 @@
         if not quiet:
             print(file=sys.stderr)
             print(
-                "warning: '%s' is not signed; falling back to signed release '%s'"
-                % (remote_ref, cur),
+                f"warning: '{remote_ref}' is not signed; "
+                f"falling back to signed release '{cur}'",
                 file=sys.stderr,
             )
             print(file=sys.stderr)
@@ -1222,7 +1220,7 @@
     if name in {"gitc-init", "help", "init"}:
         return name, []
 
-    alias = _GetRepoConfig("alias.%s" % (name,))
+    alias = _GetRepoConfig(f"alias.{name}")
     if alias is None:
         return name, []
 
@@ -1318,18 +1316,20 @@
         hard_ver = tuple(self._get_hard_ver(pkg))
         if curr_ver < hard_ver:
             print(
-                'repo: error: Your version of "%s" (%s) is unsupported; '
-                "Please upgrade to at least version %s to continue."
-                % (pkg, self._format_ver(curr_ver), self._format_ver(soft_ver)),
+                f'repo: error: Your version of "{pkg}" '
+                f"({self._format_ver(curr_ver)}) is unsupported; "
+                "Please upgrade to at least version "
+                f"{self._format_ver(soft_ver)} to continue.",
                 file=sys.stderr,
             )
             sys.exit(1)
 
         if curr_ver < soft_ver:
             print(
-                'repo: warning: Your version of "%s" (%s) is no longer supported; '
-                "Please upgrade to at least version %s to avoid breakage."
-                % (pkg, self._format_ver(curr_ver), self._format_ver(soft_ver)),
+                f'repo: error: Your version of "{pkg}" '
+                f"({self._format_ver(curr_ver)}) is no longer supported; "
+                "Please upgrade to at least version "
+                f"{self._format_ver(soft_ver)} to continue.",
                 file=sys.stderr,
             )
 
@@ -1390,20 +1390,18 @@
 def _Version():
     """Show version information."""
     print("<repo not installed>")
-    print("repo launcher version %s" % (".".join(str(x) for x in VERSION),))
-    print("       (from %s)" % (__file__,))
-    print("git %s" % (ParseGitVersion().full,))
-    print("Python %s" % sys.version)
+    print(f"repo launcher version {'.'.join(str(x) for x in VERSION)}")
+    print(f"       (from {__file__})")
+    print(f"git {ParseGitVersion().full}")
+    print(f"Python {sys.version}")
     uname = platform.uname()
     if sys.version_info.major < 3:
         # Python 3 returns a named tuple, but Python 2 is simpler.
         print(uname)
     else:
-        print("OS %s %s (%s)" % (uname.system, uname.release, uname.version))
-        print(
-            "CPU %s (%s)"
-            % (uname.machine, uname.processor if uname.processor else "unknown")
-        )
+        print(f"OS {uname.system} {uname.release} ({uname.version})")
+        processor = uname.processor if uname.processor else "unknown"
+        print(f"CPU {uname.machine} ({processor})")
     print("Bug reports:", BUG_URL)
     sys.exit(0)
 
diff --git a/ssh.py b/ssh.py
index bb89fa1..a824279 100644
--- a/ssh.py
+++ b/ssh.py
@@ -165,7 +165,7 @@
         # Check to see whether we already think that the master is running; if
         # we think it's already running, return right away.
         if port is not None:
-            key = "%s:%s" % (host, port)
+            key = f"{host}:{port}"
         else:
             key = host
 
diff --git a/subcmds/__init__.py b/subcmds/__init__.py
index 965ad0b..83ec847 100644
--- a/subcmds/__init__.py
+++ b/subcmds/__init__.py
@@ -37,9 +37,7 @@
         try:
             cmd = getattr(mod, clsn)
         except AttributeError:
-            raise SyntaxError(
-                "%s/%s does not define class %s" % (__name__, py, clsn)
-            )
+            raise SyntaxError(f"{__name__}/{py} does not define class {clsn}")
 
         name = name.replace("_", "-")
         cmd.NAME = name
diff --git a/subcmds/abandon.py b/subcmds/abandon.py
index f6c0c66..e280d69 100644
--- a/subcmds/abandon.py
+++ b/subcmds/abandon.py
@@ -117,7 +117,7 @@
             all_projects,
             callback=_ProcessResults,
             output=Progress(
-                "Abandon %s" % (nb,), len(all_projects), quiet=opt.quiet
+                f"Abandon {nb}", len(all_projects), quiet=opt.quiet
             ),
         )
 
@@ -152,4 +152,4 @@
                             _RelPath(p) for p in success[br]
                         )
                     )
-                print("%s%s| %s\n" % (br, " " * (width - len(br)), result))
+                print(f"{br}{' ' * (width - len(br))}| {result}\n")
diff --git a/subcmds/branches.py b/subcmds/branches.py
index d9a190b..59b5cb2 100644
--- a/subcmds/branches.py
+++ b/subcmds/branches.py
@@ -174,7 +174,7 @@
                         if _RelPath(p) not in have:
                             paths.append(_RelPath(p))
 
-                s = " %s %s" % (in_type, ", ".join(paths))
+                s = f" {in_type} {', '.join(paths)}"
                 if not i.IsSplitCurrent and (width + 7 + len(s) < 80):
                     fmt = out.current if i.IsCurrent else fmt
                     fmt(s)
diff --git a/subcmds/checkout.py b/subcmds/checkout.py
index ea48263..379bfa1 100644
--- a/subcmds/checkout.py
+++ b/subcmds/checkout.py
@@ -96,7 +96,7 @@
             all_projects,
             callback=_ProcessResults,
             output=Progress(
-                "Checkout %s" % (nb,), len(all_projects), quiet=opt.quiet
+                f"Checkout {nb}", len(all_projects), quiet=opt.quiet
             ),
         )
 
diff --git a/subcmds/diffmanifests.py b/subcmds/diffmanifests.py
index b446dbd..88b697b 100644
--- a/subcmds/diffmanifests.py
+++ b/subcmds/diffmanifests.py
@@ -87,25 +87,17 @@
     def _printRawDiff(self, diff, pretty_format=None, local=False):
         _RelPath = lambda p: p.RelPath(local=local)
         for project in diff["added"]:
-            self.printText(
-                "A %s %s" % (_RelPath(project), project.revisionExpr)
-            )
+            self.printText(f"A {_RelPath(project)} {project.revisionExpr}")
             self.out.nl()
 
         for project in diff["removed"]:
-            self.printText(
-                "R %s %s" % (_RelPath(project), project.revisionExpr)
-            )
+            self.printText(f"R {_RelPath(project)} {project.revisionExpr}")
             self.out.nl()
 
         for project, otherProject in diff["changed"]:
             self.printText(
-                "C %s %s %s"
-                % (
-                    _RelPath(project),
-                    project.revisionExpr,
-                    otherProject.revisionExpr,
-                )
+                f"C {_RelPath(project)} {project.revisionExpr} "
+                f"{otherProject.revisionExpr}"
             )
             self.out.nl()
             self._printLogs(
@@ -118,12 +110,8 @@
 
         for project, otherProject in diff["unreachable"]:
             self.printText(
-                "U %s %s %s"
-                % (
-                    _RelPath(project),
-                    project.revisionExpr,
-                    otherProject.revisionExpr,
-                )
+                f"U {_RelPath(project)} {project.revisionExpr} "
+                f"{otherProject.revisionExpr}"
             )
             self.out.nl()
 
diff --git a/subcmds/help.py b/subcmds/help.py
index a839131..8004071 100644
--- a/subcmds/help.py
+++ b/subcmds/help.py
@@ -150,7 +150,7 @@
     def _PrintAllCommandHelp(self):
         for name in sorted(all_commands):
             cmd = all_commands[name](manifest=self.manifest)
-            self._PrintCommandHelp(cmd, header_prefix="[%s] " % (name,))
+            self._PrintCommandHelp(cmd, header_prefix=f"[{name}] ")
 
     def _Options(self, p):
         p.add_option(
diff --git a/subcmds/info.py b/subcmds/info.py
index c24682c..f637600 100644
--- a/subcmds/info.py
+++ b/subcmds/info.py
@@ -248,7 +248,7 @@
 
             for commit in commits:
                 split = commit.split()
-                self.text("{0:38}{1} ".format("", "-"))
+                self.text(f"{'':38}{'-'} ")
                 self.sha(split[0] + " ")
                 self.text(" ".join(split[1:]))
                 self.out.nl()
diff --git a/subcmds/init.py b/subcmds/init.py
index 9ac42d8..4451787 100644
--- a/subcmds/init.py
+++ b/subcmds/init.py
@@ -215,7 +215,7 @@
 
             if not opt.quiet:
                 print()
-            print("Your identity is: %s <%s>" % (name, email))
+            print(f"Your identity is: {name} <{email}>")
             print("is this correct [y/N]? ", end="", flush=True)
             a = sys.stdin.readline().strip().lower()
             if a in ("yes", "y", "t", "true"):
diff --git a/subcmds/list.py b/subcmds/list.py
index fba6a4d..4338e1c 100644
--- a/subcmds/list.py
+++ b/subcmds/list.py
@@ -131,7 +131,7 @@
             elif opt.path_only and not opt.name_only:
                 lines.append("%s" % (_getpath(project)))
             else:
-                lines.append("%s : %s" % (_getpath(project), project.name))
+                lines.append(f"{_getpath(project)} : {project.name}")
 
         if lines:
             lines.sort()
diff --git a/subcmds/prune.py b/subcmds/prune.py
index f18471f..f99082a 100644
--- a/subcmds/prune.py
+++ b/subcmds/prune.py
@@ -83,9 +83,7 @@
             )
 
             if not branch.base_exists:
-                print(
-                    "(ignoring: tracking branch is gone: %s)" % (branch.base,)
-                )
+                print(f"(ignoring: tracking branch is gone: {branch.base})")
             else:
                 commits = branch.commits
                 date = branch.date
diff --git a/subcmds/start.py b/subcmds/start.py
index fd177f9..56008f4 100644
--- a/subcmds/start.py
+++ b/subcmds/start.py
@@ -130,7 +130,7 @@
             all_projects,
             callback=_ProcessResults,
             output=Progress(
-                "Starting %s" % (nb,), len(all_projects), quiet=opt.quiet
+                f"Starting {nb}", len(all_projects), quiet=opt.quiet
             ),
         )
 
diff --git a/subcmds/sync.py b/subcmds/sync.py
index a0a0be9..02c1d3a 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -1394,7 +1394,7 @@
 
             if username and password:
                 manifest_server = manifest_server.replace(
-                    "://", "://%s:%s@" % (username, password), 1
+                    "://", f"://{username}:{password}@", 1
                 )
 
         transport = PersistentTransport(manifest_server)
diff --git a/subcmds/version.py b/subcmds/version.py
index 71a0360..5c817f1 100644
--- a/subcmds/version.py
+++ b/subcmds/version.py
@@ -42,35 +42,28 @@
         # These might not be the same.  Report them both.
         src_ver = RepoSourceVersion()
         rp_ver = rp.bare_git.describe(HEAD)
-        print("repo version %s" % rp_ver)
-        print("       (from %s)" % rem.url)
-        print("       (tracking %s)" % branch.merge)
-        print("       (%s)" % rp.bare_git.log("-1", "--format=%cD", HEAD))
+        print(f"repo version {rp_ver}")
+        print(f"       (from {rem.url})")
+        print(f"       (tracking {branch.merge})")
+        print(f"       ({rp.bare_git.log('-1', '--format=%cD', HEAD)})")
 
         if self.wrapper_path is not None:
-            print("repo launcher version %s" % self.wrapper_version)
-            print("       (from %s)" % self.wrapper_path)
+            print(f"repo launcher version {self.wrapper_version}")
+            print(f"       (from {self.wrapper_path})")
 
             if src_ver != rp_ver:
-                print("       (currently at %s)" % src_ver)
+                print(f"       (currently at {src_ver})")
 
-        print("repo User-Agent %s" % user_agent.repo)
-        print("git %s" % git.version_tuple().full)
-        print("git User-Agent %s" % user_agent.git)
-        print("Python %s" % sys.version)
+        print(f"repo User-Agent {user_agent.repo}")
+        print(f"git {git.version_tuple().full}")
+        print(f"git User-Agent {user_agent.git}")
+        print(f"Python {sys.version}")
         uname = platform.uname()
         if sys.version_info.major < 3:
             # Python 3 returns a named tuple, but Python 2 is simpler.
             print(uname)
         else:
-            print(
-                "OS %s %s (%s)" % (uname.system, uname.release, uname.version)
-            )
-            print(
-                "CPU %s (%s)"
-                % (
-                    uname.machine,
-                    uname.processor if uname.processor else "unknown",
-                )
-            )
+            print(f"OS {uname.system} {uname.release} ({uname.version})")
+            processor = uname.processor if uname.processor else "unknown"
+            print(f"CPU {uname.machine} ({processor})")
         print("Bug reports:", Wrapper().BUG_URL)
diff --git a/tests/test_git_command.py b/tests/test_git_command.py
index 7c108cc..ffee023 100644
--- a/tests/test_git_command.py
+++ b/tests/test_git_command.py
@@ -19,12 +19,7 @@
 import re
 import subprocess
 import unittest
-
-
-try:
-    from unittest import mock
-except ImportError:
-    import mock
+from unittest import mock
 
 import git_command
 import wrapper
diff --git a/tests/test_git_config.py b/tests/test_git_config.py
index a44dca0..cf6e779 100644
--- a/tests/test_git_config.py
+++ b/tests/test_git_config.py
@@ -100,7 +100,7 @@
             ("intg", 10737418240),
         )
         for key, value in TESTS:
-            self.assertEqual(value, self.config.GetInt("section.%s" % (key,)))
+            self.assertEqual(value, self.config.GetInt(f"section.{key}"))
 
 
 class GitConfigReadWriteTests(unittest.TestCase):
diff --git a/tests/test_git_superproject.py b/tests/test_git_superproject.py
index 478ebca..4e66521 100644
--- a/tests/test_git_superproject.py
+++ b/tests/test_git_superproject.py
@@ -34,7 +34,7 @@
     PARENT_SID_KEY = "GIT_TRACE2_PARENT_SID"
     PARENT_SID_VALUE = "parent_sid"
     SELF_SID_REGEX = r"repo-\d+T\d+Z-.*"
-    FULL_SID_REGEX = r"^%s/%s" % (PARENT_SID_VALUE, SELF_SID_REGEX)
+    FULL_SID_REGEX = rf"^{PARENT_SID_VALUE}/{SELF_SID_REGEX}"
 
     def setUp(self):
         """Set up superproject every time."""
diff --git a/tests/test_git_trace2_event_log.py b/tests/test_git_trace2_event_log.py
index d8e963d..4658a79 100644
--- a/tests/test_git_trace2_event_log.py
+++ b/tests/test_git_trace2_event_log.py
@@ -61,7 +61,7 @@
     PARENT_SID_KEY = "GIT_TRACE2_PARENT_SID"
     PARENT_SID_VALUE = "parent_sid"
     SELF_SID_REGEX = r"repo-\d+T\d+Z-.*"
-    FULL_SID_REGEX = r"^%s/%s" % (PARENT_SID_VALUE, SELF_SID_REGEX)
+    FULL_SID_REGEX = rf"^{PARENT_SID_VALUE}/{SELF_SID_REGEX}"
 
     def setUp(self):
         """Load the event_log module every time."""
diff --git a/tests/test_manifest_xml.py b/tests/test_manifest_xml.py
index bd255dc..3fcf09f 100644
--- a/tests/test_manifest_xml.py
+++ b/tests/test_manifest_xml.py
@@ -198,13 +198,13 @@
     def test_bool_true(self):
         """Check XmlBool true values."""
         for value in ("yes", "true", "1"):
-            node = self._get_node('<node a="%s"/>' % (value,))
+            node = self._get_node(f'<node a="{value}"/>')
             self.assertTrue(manifest_xml.XmlBool(node, "a"))
 
     def test_bool_false(self):
         """Check XmlBool false values."""
         for value in ("no", "false", "0"):
-            node = self._get_node('<node a="%s"/>' % (value,))
+            node = self._get_node(f'<node a="{value}"/>')
             self.assertFalse(manifest_xml.XmlBool(node, "a"))
 
     def test_int_default(self):
@@ -220,7 +220,7 @@
     def test_int_good(self):
         """Check XmlInt numeric handling."""
         for value in (-1, 0, 1, 50000):
-            node = self._get_node('<node a="%s"/>' % (value,))
+            node = self._get_node(f'<node a="{value}"/>')
             self.assertEqual(value, manifest_xml.XmlInt(node, "a"))
 
     def test_int_invalid(self):
diff --git a/tests/test_project.py b/tests/test_project.py
index 83cfe0a..6dc071b 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -151,7 +151,7 @@
                     # "".
                     break
                 result = os.path.exists(path)
-                msg.append("\tos.path.exists(%s): %s" % (path, result))
+                msg.append(f"\tos.path.exists({path}): {result}")
                 if result:
                     msg.append("\tcontents: %r" % os.listdir(path))
                     break