Repository: miurahr/aqtinstall
Pull request #620 (2022-11-20 03:42:14)

Add typecheck and fix errors
Re-create commit history based on #617  for merge.


Patch:
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
index f1adba0..71c1d2f 100644
--- a/.github/workflows/check.yml
+++ b/.github/workflows/check.yml
@@ -1,4 +1,4 @@
-name: Check package
+name: Check tox tests, lint and types
 
 on:
   push:
diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-to-pypi.yml
index dd98261..5d8da89 100644
--- a/.github/workflows/publish-to-pypi.yml
+++ b/.github/workflows/publish-to-pypi.yml
@@ -20,10 +20,10 @@ jobs:
           fetch-depth: 50
       - name: Fetch release tag
         run: git fetch --depth=1 origin +refs/tags/*:refs/tags/*
-      - name: Set up Python 3.9🐍
+      - name: Set up Python 3.10🐍
         uses: actions/setup-python@v4
         with:
-          python-version: '3.9'
+          python-version: '3.10'
       - name: Install build and twine
         run: python -m pip install build twine --user
       - name: Build release assets
diff --git a/aqt/archives.py b/aqt/archives.py
index 4aadcbe..77eb5d7 100644
--- a/aqt/archives.py
+++ b/aqt/archives.py
@@ -23,6 +23,7 @@ import posixpath
 from dataclasses import dataclass, field
 from logging import getLogger
 from typing import Dict, Iterable, List, Optional, Tuple
+from xml.etree.ElementTree import Element  # noqa
 
 from defusedxml import ElementTree
 
@@ -245,13 +246,12 @@ class Updates:
                                 filo.append(depend)
         return packages
 
-    def _get_text(self, item) -> str:
+    def _get_text(self, item: Optional[Element]) -> str:
         if item is not None and item.text is not None:
             return item.text
-        else:
-            return ""
+        return ""
 
-    def _get_list(self, item) -> Iterable[str]:
+    def _get_list(self, item: Optional[Element]) -> Iterable[str]:
         if item is not None and item.text is not None:
             return ssplit(item.text)
         else:
@@ -350,8 +350,6 @@ class QtArchives:
             return ModuleToPackage({})
         base_package = {self._base_module_name(): list(self._base_package_names())}
         target_packages = ModuleToPackage(base_package if self.is_include_base_package else {})
-        if self.all_extra:
-            return target_packages
         for module in self.mod_list:
             suffix = self._module_name_suffix(module)
             package_names = [
@@ -407,11 +405,11 @@ class QtArchives:
         for packageupdate in package_updates:
             if not self.all_extra:
                 target_packages.remove_module_for_package(packageupdate.name)
-            should_filter_archives: bool = self.subarchives and self.should_filter_archives(packageupdate.name)
+            should_filter_archives: bool = bool(self.subarchives) and self.should_filter_archives(packageupdate.name)
 
             for archive in packageupdate.downloadable_archives:
                 archive_name = archive.split("-", maxsplit=1)[0]
-                if should_filter_archives and archive_name not in self.subarchives:
+                if should_filter_archives and self.subarchives is not None and archive_name not in self.subarchives:
                     continue
                 archive_path = posixpath.join(
                     # online/qtsdkrepository/linux_x64/desktop/qt5_5150/
@@ -508,7 +506,7 @@ class SrcDocExamplesArchives(QtArchives):
 
     def __init__(
         self,
-        flavor,
+        flavor: str,
         os_name,
         target,
         version,
@@ -519,7 +517,7 @@ class SrcDocExamplesArchives(QtArchives):
         is_include_base_package: bool = True,
         timeout=(5, 5),
     ):
-        self.flavor = flavor
+        self.flavor: str = flavor
         self.target = target
         self.os_name = os_name
         self.base = base
@@ -584,8 +582,8 @@ class ToolArchives(QtArchives):
         tool_name: str,
         base: str,
         version_str: Optional[str] = None,
-        arch: Optional[str] = None,
-        timeout: Tuple[int, int] = (5, 5),
+        arch: str = "",
+        timeout: Tuple[float, float] = (5, 5),
     ):
         self.tool_name = tool_name
         self.os_name = os_name
diff --git a/aqt/exceptions.py b/aqt/exceptions.py
index f76a6ad..012a8c8 100644
--- a/aqt/exceptions.py
+++ b/aqt/exceptions.py
@@ -29,7 +29,7 @@ class AqtException(Exception):
         self.should_show_help: bool = should_show_help or False
         super(AqtException, self).__init__(*args, **kwargs)
 
-    def __format__(self, format_spec) -> str:
+    def __format__(self, format_spec: str) -> str:
         base_msg = "{}".format(super(AqtException, self).__format__(format_spec))
         if not self.suggested_action:
             return base_msg
@@ -40,7 +40,7 @@ class AqtException(Exception):
             ["* " + suggestion for suggestion in self.suggested_action]
         )
 
-    def append_suggested_follow_up(self, suggestions: List[str]):
+    def append_suggested_follow_up(self, suggestions: List[str]) -> None:
         self.suggested_action.extend(suggestions)
 
 
diff --git a/aqt/helper.py b/aqt/helper.py
index bd524c6..6208a89 100644
--- a/aqt/helper.py
+++ b/aqt/helper.py
@@ -27,10 +27,10 @@ import os
 import posixpath
 import secrets
 import sys
-from logging import getLogger
+from logging import Handler, getLogger
 from logging.handlers import QueueListener
 from pathlib import Path
-from typing import Callable, Dict, Generator, List, Optional, Tuple
+from typing import Any, Callable, Dict, Generator, List, Optional, TextIO, Tuple, Union
 from urllib.parse import urlparse
 from xml.etree.ElementTree import Element
 
@@ -48,7 +48,7 @@ from aqt.exceptions import (
 )
 
 
-def _get_meta(url: str):
+def _get_meta(url: str) -> requests.Response:
     return requests.get(url + ".meta4")
 
 
@@ -57,7 +57,7 @@ def _check_content_type(ct: str) -> bool:
     return any(ct.startswith(t) for t in candidate)
 
 
-def getUrl(url: str, timeout, expected_hash: Optional[bytes] = None) -> str:
+def getUrl(url: str, timeout: Tuple[float, float], expected_hash: Optional[bytes] = None) -> str:
     """
     Gets a file from `url` via HTTP GET.
 
@@ -91,7 +91,7 @@ def getUrl(url: str, timeout, expected_hash: Optional[bytes] = None) -> str:
             if r.status_code != 200:
                 msg = f"Failed to retrieve file at {url}\nServer response code: {r.status_code}, reason: {r.reason}"
                 raise ArchiveDownloadError(msg)
-        result = r.text
+        result: str = r.text
         filename = url.split("/")[-1]
         actual_hash = hashlib.sha256(bytes(result, "utf-8")).digest()
         if expected_hash is not None and expected_hash != actual_hash:
@@ -103,7 +103,7 @@ def getUrl(url: str, timeout, expected_hash: Optional[bytes] = None) -> str:
     return result
 
 
-def downloadBinaryFile(url: str, out: Path, hash_algo: str, exp: bytes, timeout):
+def downloadBinaryFile(url: str, out: Path, hash_algo: str, exp: bytes, timeout: Tuple[float, float]) -> None:
     logger = getLogger("aqt.helper")
     filename = Path(url).name
     with requests.sessions.Session() as session:
@@ -142,7 +142,7 @@ def downloadBinaryFile(url: str, out: Path, hash_algo: str, exp: bytes, timeout)
                 )
 
 
-def retry_on_errors(action: Callable[[], any], acceptable_errors: Tuple, num_retries: int, name: str):
+def retry_on_errors(action: Callable[[], Any], acceptable_errors: Tuple, num_retries: int, name: str) -> Any:
     logger = getLogger("aqt.helper")
     for i in range(num_retries):
         try:
@@ -158,7 +158,7 @@ def retry_on_errors(action: Callable[[], any], acceptable_errors: Tuple, num_ret
             raise e from e
 
 
-def retry_on_bad_connection(function: Callable[[str], any], base_url: str):
+def retry_on_bad_connection(function: Callable[[str], Any], base_url: str) -> Any:
     logger = getLogger("aqt.helper")
     fallback_url = secrets.choice(Settings.fallbacks)
     try:
@@ -177,7 +177,7 @@ def iter_list_reps(_list: List, num_reps: int) -> Generator:
             list_index = 0
 
 
-def get_hash(archive_path: str, algorithm: str, timeout) -> bytes:
+def get_hash(archive_path: str, algorithm: str, timeout: Tuple[float, float]) -> bytes:
     """
     Downloads a checksum and unhexlifies it to a `bytes` object, guaranteed to be the right length.
     Raises ChecksumDownloadFailure if the download failed, or if the checksum was un unexpected length.
@@ -206,11 +206,11 @@ def get_hash(archive_path: str, algorithm: str, timeout) -> bytes:
     )
 
 
-def altlink(url: str, alt: str):
+def altlink(url: str, alt: str) -> str:
     """
-    Blacklisting redirected(alt) location based on Settings.blacklist configuration.
-    When found black url, then try download a url + .meta4 that is a metalink version4
-    xml file, parse it and retrieve best alternative url.
+    Blacklisting redirected(alt) location based on Settings. Blacklist configuration.
+    When found black url, then try download an url + .meta4 that is a metalink version4
+    xml file, parse it and retrieve the best alternative url.
     """
     logger = getLogger("aqt.helper")
     if not any(alt.startswith(b) for b in Settings.blacklist):
@@ -227,7 +227,7 @@ def altlink(url: str, alt: str):
             return alt
         try:
             mirror_xml = ElementTree.fromstring(m.text)
-            meta_urls = {}
+            meta_urls: Dict[str, str] = {}
             for f in mirror_xml.iter("{urn:ietf:params:xml:ns:metalink}file"):
                 for u in f.iter("{urn:ietf:params:xml:ns:metalink}url"):
                     meta_urls[u.attrib["priority"]] = u.text
@@ -239,21 +239,18 @@ def altlink(url: str, alt: str):
         else:
             # Return first priority item which is not blacklist in mirrors list,
             # if not found then return alt in default
-            return next(
-                filter(
-                    lambda mirror: not any(mirror.startswith(b) for b in Settings.blacklist),
-                    mirrors,
-                ),
-                alt,
-            )
+            try:
+                return next(mirror for mirror in mirrors if not any(mirror.startswith(b) for b in Settings.blacklist))
+            except StopIteration:
+                return alt
 
 
 class MyQueueListener(QueueListener):
     def __init__(self, queue):
-        handlers = []
+        handlers: List[Handler] = []
         super().__init__(queue, *handlers)
 
-    def handle(self, record):
+    def handle(self, record) -> None:
         """
         Handle a record from subprocess.
         Override logger name then handle at proper logger.
@@ -264,7 +261,7 @@ class MyQueueListener(QueueListener):
         logger.handle(record)
 
 
-def ssplit(data: str):
+def ssplit(data: str) -> Generator[str, None, None]:
     for element in data.split(","):
         yield element.strip()
 
@@ -284,9 +281,9 @@ def xml_to_modules(
         parsed_xml = ElementTree.fromstring(xml_text)
     except ElementTree.ParseError as perror:
         raise ArchiveListError(f"Downloaded metadata is corrupted. {perror}") from perror
-    packages = {}
+    packages: Dict[str, Dict[str, str]] = {}
     for packageupdate in parsed_xml.iter("PackageUpdate"):
-        if predicate and not predicate(packageupdate):
+        if not predicate(packageupdate):
             continue
         name = packageupdate.find("Name").text
         packages[name] = {}
@@ -302,7 +299,7 @@ def xml_to_modules(
 
 
 class MyConfigParser(configparser.ConfigParser):
-    def getlist(self, section: str, option: str, fallback=[]) -> List[str]:
+    def getlist(self, section: str, option: str, fallback: List[str] = []) -> List[str]:
         value = self.get(section, option, fallback=None)
         if value is None:
             return fallback
@@ -312,7 +309,7 @@ class MyConfigParser(configparser.ConfigParser):
             result = fallback
         return result
 
-    def getlistint(self, section: str, option: str, fallback=[]):
+    def getlistint(self, section: str, option: str, fallback: List[int] = []) -> List[int]:
         try:
             result = [int(x) for x in self.getlist(section, option)]
         except Exception:
@@ -332,7 +329,7 @@ class SettingsClass:
         self.configfile = os.path.join(os.path.dirname(__file__), "settings.ini")
         self.loggingconf = os.path.join(os.path.dirname(__file__), "logging.ini")
 
-    def load_settings(self, file=None):
+    def load_settings(self, file: Optional[Union[str, TextIO]] = None) -> None:
         with open(
             os.path.join(os.path.dirname(__file__), "combinations.json"),
             "r",
@@ -347,15 +344,11 @@ class SettingsClass:
             else:
                 # passed through command line argparse.FileType("r")
                 self.config.read_file(file)
-                self.configfile = file
+                self.configfile = file.name
                 file.close()
         else:
-            if isinstance(self.configfile, str):
-                with open(self.configfile, "r") as f:
-                    self.config.read_file(f)
-            else:
-                self.configfile.seek(0)
-                self.config.read_file(self.configfile)
+            with open(self.configfile, "r") as f:
+                self.config.read_file(f)
 
     @property
     def qt_combinations(self):
diff --git a/aqt/installer.py b/aqt/installer.py
index 1f5584c..33aec9a 100644
--- a/aqt/installer.py
+++ b/aqt/installer.py
@@ -35,7 +35,7 @@ from logging import getLogger
 from logging.handlers import QueueHandler
 from pathlib import Path
 from tempfile import TemporaryDirectory
-from typing import List, Optional, Tuple
+from typing import List, Optional, Tuple, cast
 
 import aqt
 from aqt.archives import QtArchives, QtPackage, SrcDocExamplesArchives, TargetConfig, ToolArchives
@@ -69,6 +69,75 @@ except ImportError:
     EXT7Z = True
 
 
+class BaseArgumentParser(argparse.ArgumentParser):
+    """Global options and subcommand trick"""
+
+    config: Optional[str]
+    func: object
+
+
+class ListArgumentParser(BaseArgumentParser):
+    """List-* command parser arguments and options"""
+
+    arch: Optional[str]
+    archives: List[str]
+    extension: str
+    extensions: str
+    host: str
+    last_version: str
+    latest_version: bool
+    long: bool
+    long_modules: List[str]
+    modules: List[str]
+    qt_version_spec: str
+    spec: str
+    target: str
+
+
+class ListToolArgumentParser(ListArgumentParser):
+    """List-tool command options"""
+
+    tool_name: str
+    tool_version: str
+
+
+class CommonInstallArgParser(BaseArgumentParser):
+    """Install-*/install common arguments"""
+
+    is_legacy: bool
+    target: str
+    host: str
+
+    outputdir: Optional[str]
+    base: Optional[str]
+    timeout: Optional[float]
+    external: Optional[str]
+    internal: bool
+    keep: bool
+    archive_dest: Optional[str]
+
+
+class InstallArgParser(CommonInstallArgParser):
+    """Install-qt arguments and options"""
+
+    arch: Optional[str]
+    qt_version: str
+    qt_version_spec: str
+
+    modules: Optional[List[str]]
+    archives: Optional[List[str]]
+    noarchives: bool
+    autodesktop: bool
+
+
+class InstallToolArgParser(CommonInstallArgParser):
+    """Install-tool arguments and options"""
+
+    tool_name: str
+    version: Optional[str]
+    tool_variant: Optional[str]
+
+
 class Cli:
     """CLI main class to parse command line argument and launch proper functions."""
 
@@ -249,7 +318,7 @@ class Cli:
         dest.mkdir(parents=True, exist_ok=True)
         return dest
 
-    def run_install_qt(self, args):
+    def run_install_qt(self, args: InstallArgParser):
         """Run install subcommand"""
         start_time = time.perf_counter()
         self.show_aqt_version()
@@ -257,9 +326,8 @@ class Cli:
             self._warn_on_deprecated_command("install", "install-qt")
         target: str = args.target
         os_name: str = args.host
-        arch: str = self._set_arch(
-            args.arch, os_name, target, getattr(args, "qt_version", getattr(args, "qt_version_spec", None))
-        )
+        qt_version_or_spec: str = getattr(args, "qt_version", getattr(args, "qt_version_spec", ""))
+        arch: str = self._set_arch(args.arch, os_name, target, qt_version_or_spec)
         keep: bool = args.keep or Settings.always_keep_archives
         archive_dest: Optional[str] = args.archive_dest
         output_dir = args.outputdir
@@ -288,7 +356,7 @@ class Cli:
         if hasattr(args, "qt_version_spec"):
             qt_version: str = str(Cli._determine_qt_version(args.qt_version_spec, os_name, target, arch, base_url=base))
         else:
-            qt_version: str = args.qt_version
+            qt_version = args.qt_version
             Cli._validate_version_str(qt_version)
         archives = args.archives
         if args.noarchives:
@@ -298,7 +366,7 @@ class Cli:
                 raise CliInputError("Options `--archives` and `--noarchives` are mutually exclusive.")
         else:
             if modules is not None and archives is not None:
-                archives.append(modules)
+                archives.extend(modules)
         nopatch = args.noarchives or (archives is not None and "qtbase" not in archives)  # type: bool
         should_autoinstall: bool = args.autodesktop
         _version = Version(qt_version)
@@ -308,14 +376,16 @@ class Cli:
             should_autoinstall, os_name, target, base_path, _version, is_wasm=(arch.startswith("wasm"))
         )
 
-        auto_desktop_archives: List[QtPackage] = (
-            retry_on_bad_connection(
-                lambda base_url: QtArchives(os_name, "desktop", qt_version, autodesk_arch, base=base_url, timeout=timeout),
-                base,
-            ).archives
-            if autodesk_arch is not None
-            else []
-        )
+        def get_auto_desktop_archives() -> List[QtPackage]:
+            def to_archives(baseurl: str) -> QtArchives:
+                return QtArchives(os_name, "desktop", qt_version, cast(str, autodesk_arch), base=baseurl, timeout=timeout)
+
+            if autodesk_arch is not None:
+                return cast(QtArchives, retry_on_bad_connection(to_archives, base)).archives
+            else:
+                return []
+
+        auto_desktop_archives: List[QtPackage] = get_auto_desktop_archives()
 
         if not self._check_qt_arg_versions(qt_version):
             self.logger.warning("Specified Qt version is unknown: {}.".format(qt_version))
@@ -357,12 +427,12 @@ class Cli:
         self.logger.info("Time elapsed: {time:.8f} second".format(time=time.perf_counter() - start_time))
 
     def _run_src_doc_examples(self, flavor, args, cmd_name: Optional[str] = None):
-        if not cmd_name:
-            cmd_name = flavor
-
         self.show_aqt_version()
         if args.is_legacy:
-            self._warn_on_deprecated_command(old_name=cmd_name, new_name=f"install-{cmd_name}")
+            if cmd_name is None:
+                self._warn_on_deprecated_command(old_name=flavor, new_name=f"install-{flavor}")
+            else:
+                self._warn_on_deprecated_command(old_name=cmd_name, new_name=f"install-{cmd_name}")
         elif getattr(args, "target", None) is not None:
             self._warn_on_deprecated_parameter("target", args.target)
         target = "desktop"  # The only valid target for src/doc/examples is "desktop"
@@ -447,7 +517,7 @@ class Cli:
         self._run_src_doc_examples("doc", args)
         self.logger.info("Time elapsed: {time:.8f} second".format(time=time.perf_counter() - start_time))
 
-    def run_install_tool(self, args):
+    def run_install_tool(self, args: InstallToolArgParser):
         """Run tool subcommand"""
         start_time = time.perf_counter()
         self.show_aqt_version()
@@ -482,7 +552,7 @@ class Cli:
             archive_id = ArchiveId("tools", os_name, target)
             meta = MetadataFactory(archive_id, base_url=base, is_latest_version=True, tool_name=tool_name)
             try:
-                archs = meta.getList()
+                archs: List[str] = cast(list, meta.getList())
             except ArchiveDownloadError as e:
                 msg = f"Failed to locate XML data for the tool '{tool_name}'."
                 raise ArchiveListError(msg, suggested_action=suggested_follow_up(meta)) from e
@@ -512,7 +582,7 @@ class Cli:
         self.logger.info("Finished installation")
         self.logger.info("Time elapsed: {time:.8f} second".format(time=time.perf_counter() - start_time))
 
-    def run_list_qt(self, args: argparse.ArgumentParser):
+    def run_list_qt(self, args: ListArgumentParser):
         """Print versions of Qt, extensions, modules, architectures"""
 
         if args.extensions:
@@ -533,9 +603,13 @@ class Cli:
         if args.target not in ArchiveId.TARGETS_FOR_HOST[args.host]:
             raise CliInputError("'{0.target}' is not a valid target for host '{0.host}'".format(args))
         if args.modules:
-            modules_ver, modules_query, is_long = args.modules[0], tuple(args.modules), False
+            assert len(args.modules) == 2, "broken argument parser for list-qt"
+            modules_query = MetadataFactory.ModulesQuery(args.modules[0], args.modules[1])
+            modules_ver, is_long = args.modules[0], False
         elif args.long_modules:
-            modules_ver, modules_query, is_long = args.long_modules[0], tuple(args.long_modules), True
+            assert args.long_modules and len(args.long_modules) == 2, "broken argument parser for list-qt"
+            modules_query = MetadataFactory.ModulesQuery(args.long_modules[0], args.long_modules[1])
+            modules_ver, is_long = args.long_modules[0], True
         else:
             modules_ver, modules_query, is_long = None, None, False
 
@@ -560,7 +634,7 @@ class Cli:
         )
         show_list(meta)
 
-    def run_list_tool(self, args: argparse.ArgumentParser):
+    def run_list_tool(self, args: ListToolArgumentParser):
         """Print tools"""
 
         if not args.target:
@@ -576,13 +650,13 @@ class Cli:
         )
         show_list(meta)
 
-    def run_list_src_doc_examples(self, args: argparse.ArgumentParser, cmd_type: str):
+    def run_list_src_doc_examples(self, args: ListArgumentParser, cmd_type: str):
         target = "desktop"  # The only valid target for src/doc/examples is "desktop"
         version = Cli._determine_qt_version(args.qt_version_spec, args.host, target, arch="")
         is_fetch_modules: bool = getattr(args, "modules", False)
         meta = MetadataFactory(
             archive_id=ArchiveId("qt", args.host, target),
-            src_doc_examples_query=(cmd_type, version, is_fetch_modules),
+            src_doc_examples_query=MetadataFactory.SrcDocExamplesQuery(cmd_type, version, is_fetch_modules),
         )
         show_list(meta)
 
@@ -662,7 +736,7 @@ class Cli:
         )
         self._set_common_options(install_tool_parser)
 
-    def _warn_on_deprecated_command(self, old_name: str, new_name: str):
+    def _warn_on_deprecated_command(self, old_name: str, new_name: str) -> None:
         self.logger.warning(
             f"The command '{old_name}' is deprecated and marked for removal in a future version of aqt.\n"
             f"In the future, please use the command '{new_name}' instead."
@@ -675,7 +749,7 @@ class Cli:
             f"In the future, please omit this parameter."
         )
 
-    def _make_all_parsers(self, subparsers: argparse._SubParsersAction):
+    def _make_all_parsers(self, subparsers: argparse._SubParsersAction) -> None:
         deprecated_msg = "This command is deprecated and marked for removal in a future version of aqt."
 
         def make_parser_it(cmd: str, desc: str, is_legacy: bool, set_parser_cmd, formatter_class):
@@ -731,7 +805,7 @@ class Cli:
 
     def _make_list_qt_parser(self, subparsers: argparse._SubParsersAction):
         """Creates a subparser that works with the MetadataFactory, and adds it to the `subparsers` parameter"""
-        list_parser: argparse.ArgumentParser = subparsers.add_parser(
+        list_parser: ListArgumentParser = subparsers.add_parser(
             "list-qt",
             formatter_class=argparse.RawDescriptionHelpFormatter,
             epilog="Examples:\n"
@@ -820,7 +894,7 @@ class Cli:
 
     def _make_list_tool_parser(self, subparsers: argparse._SubParsersAction):
         """Creates a subparser that works with the MetadataFactory, and adds it to the `subparsers` parameter"""
-        list_parser: argparse.ArgumentParser = subparsers.add_parser(
+        list_parser: ListArgumentParser = subparsers.add_parser(
             "list-tool",
             formatter_class=argparse.RawDescriptionHelpFormatter,
             epilog="Examples:\n"
@@ -952,7 +1026,7 @@ class Cli:
 
     @staticmethod
     def _validate_version_str(
-        version_str: str, *, allow_latest: bool = False, allow_empty: bool = False, allow_minus: bool = False
+        version_str: Optional[str], *, allow_latest: bool = False, allow_empty: bool = False, allow_minus: bool = False
     ) -> None:
         """
         Raise CliInputError if the version is not an acceptable Version.
@@ -963,8 +1037,13 @@ class Cli:
         :param allow_minus: If true, everything after the first '-' in the version will be ignored.
                             This allows acceptance of versions like "1.2.3-0-202101020304"
         """
-        if (allow_latest and version_str == "latest") or (allow_empty and not version_str):
+        if allow_latest and version_str == "latest":
             return
+        if not version_str:
+            if allow_empty:
+                return
+            else:
+                raise CliInputError("Invalid empty version! Please use the form '5.X.Y'.")
         try:
             if "-" in version_str and allow_minus:
                 version_str = version_str[: version_str.find("-")]
@@ -1119,18 +1198,7 @@ def installer(
         with py7zr.SevenZipFile(archive, "r") as szf:
             szf.extractall(path=base_dir)
     else:
-        if base_dir is not None:
-            command_args = [
-                command,
-                "x",
-                "-aoa",
-                "-bd",
-                "-y",
-                "-o{}".format(base_dir),
-                str(archive),
-            ]
-        else:
-            command_args = [command, "x", "-aoa", "-bd", "-y", str(archive)]
+        command_args = [command, "x", "-aoa", "-bd", "-y", "-o{}".format(base_dir), str(archive)]
         try:
             proc = subprocess.run(command_args, stdout=subprocess.PIPE, check=True)
             logger.debug(proc.stdout)
diff --git a/aqt/metadata.py b/aqt/metadata.py
index 6ae5128..11c9ec0 100644
--- a/aqt/metadata.py
+++ b/aqt/metadata.py
@@ -28,7 +28,7 @@ from abc import ABC, abstractmethod
 from functools import reduce
 from logging import getLogger
 from pathlib import Path
-from typing import Callable, Dict, Generator, Iterable, Iterator, List, Optional, Tuple, Union
+from typing import Callable, Dict, Generator, Iterable, Iterator, List, NamedTuple, Optional, Set, Tuple, Union, cast
 from urllib.parse import ParseResult, urlparse
 from xml.etree.ElementTree import Element
 
@@ -135,11 +135,11 @@ class Versions:
         versions: Union[None, Version, Iterable[Tuple[int, Iterable[Version]]]],
     ):
         if versions is None:
-            self.versions = list()
+            self.versions: List[List[Version]] = list()
         elif isinstance(versions, Version):
             self.versions = [[versions]]
         else:
-            self.versions: List[List[Version]] = [list(versions_iterator) for _, versions_iterator in versions]
+            self.versions = [list(versions_iterator) for _, versions_iterator in versions]
 
     def __str__(self) -> str:
         return str(self.versions)
@@ -320,7 +320,7 @@ class TableMetadata(ABC):
         heading = [self.name_heading, *[self.map_key_to_heading(key) for key in heading_keys]]
         table.header(heading)
         table.add_rows(self._rows(heading_keys), header=False)
-        return table.draw()
+        return cast(str, table.draw())
 
     def __bool__(self):
         return bool(self.table_data)
@@ -373,9 +373,7 @@ class QtRepoProperty:
 
     @staticmethod
     def get_arch_dir_name(host: str, arch: str, version: Version) -> str:
-        if arch is None:
-            return ""
-        elif arch.startswith("win64_mingw"):
+        if arch.startswith("win64_mingw"):
             return arch[6:] + "_64"
         elif arch.startswith("win32_mingw"):
             return arch[6:] + "_32"
@@ -494,6 +492,13 @@ class QtRepoProperty:
 class MetadataFactory:
     """Retrieve metadata of Qt variations, versions, and descriptions from Qt site."""
 
+    Metadata = Union[List[str], Versions, ToolData, ModuleData]
+    Action = Callable[[], Metadata]
+    SrcDocExamplesQuery = NamedTuple(
+        "SrcDocExamplesQuery", [("cmd_type", str), ("version", Version), ("is_modules_query", bool)]
+    )
+    ModulesQuery = NamedTuple("ModulesQuery", [("version_str", str), ("arch", str)])
+
     def __init__(
         self,
         archive_id: ArchiveId,
@@ -501,10 +506,10 @@ class MetadataFactory:
         base_url: str = Settings.baseurl,
         spec: Optional[SimpleSpec] = None,
         is_latest_version: bool = False,
-        modules_query: Optional[Tuple[str, str]] = None,
+        modules_query: Optional[ModulesQuery] = None,
         architectures_ver: Optional[str] = None,
         archives_query: Optional[List[str]] = None,
-        src_doc_examples_query: Optional[Tuple[str, Version, bool]] = None,
+        src_doc_examples_query: Optional[SrcDocExamplesQuery] = None,
         tool_name: Optional[str] = None,
         is_long_listing: bool = False,
     ):
@@ -527,52 +532,51 @@ class MetadataFactory:
         self.base_url = base_url
 
         if archive_id.is_tools():
-            if tool_name:
-                if not tool_name.startswith("tools_"):
-                    tool_name = "tools_" + tool_name
+            if tool_name is not None:
+                _tool_name: str = "tools_" + tool_name if not tool_name.startswith("tools_") else tool_name
                 if is_long_listing:
                     self.request_type = "tool long listing"
-                    self._action = lambda: self.fetch_tool_long_listing(tool_name)
+                    self._action: MetadataFactory.Action = lambda: self.fetch_tool_long_listing(_tool_name)
                 else:
                     self.request_type = "tool variant names"
-                    self._action = lambda: self.fetch_tool_modules(tool_name)
+                    self._action = lambda: self.fetch_tool_modules(_tool_name)
             else:
                 self.request_type = "tools"
                 self._action = self.fetch_tools
         elif is_latest_version:
             self.request_type = "latest version"
             self._action = lambda: Versions(self.fetch_latest_version(ext=""))
-        elif modules_query:
+        elif modules_query is not None:
+            version, arch = modules_query.version_str, modules_query.arch
             if is_long_listing:
                 self.request_type = "long modules"
-                version, arch = modules_query
                 self._action = lambda: self.fetch_long_modules(self._to_version(version, arch), arch)
             else:
                 self.request_type = "modules"
-                version, arch = modules_query
                 self._action = lambda: self.fetch_modules(self._to_version(version, arch), arch)
-        elif architectures_ver:
+        elif architectures_ver is not None:
+            ver_str: str = architectures_ver
             self.request_type = "architectures"
-            self._action = lambda: self.fetch_arches(self._to_version(architectures_ver, None))
+            self._action = lambda: self.fetch_arches(self._to_version(ver_str, None))
         elif archives_query:
             if len(archives_query) < 2:
                 raise CliInputError("The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter.")
             self.request_type = "archives for modules" if len(archives_query) > 2 else "archives for qt"
             version, arch, modules = archives_query[0], archives_query[1], archives_query[2:]
             self._action = lambda: self.fetch_archives(self._to_version(version, arch), arch, modules)
-        elif src_doc_examples_query:
-            cmd_type, version, is_modules_query = src_doc_examples_query
-            if is_modules_query:
-                self.request_type = f"modules for {cmd_type}"
-                self._action = lambda: self.fetch_modules_sde(cmd_type, version)
+        elif src_doc_examples_query is not None:
+            q: MetadataFactory.SrcDocExamplesQuery = src_doc_examples_query
+            if q.is_modules_query:
+                self.request_type = f"modules for {q.cmd_type}"
+                self._action = lambda: self.fetch_modules_sde(q.cmd_type, q.version)
             else:
-                self.request_type = f"archives for {cmd_type}"
-                self._action = lambda: self.fetch_archives_sde(cmd_type, version)
+                self.request_type = f"archives for {q.cmd_type}"
+                self._action = lambda: self.fetch_archives_sde(q.cmd_type, q.version)
         else:
             self.request_type = "versions"
             self._action = self.fetch_versions
 
-    def getList(self) -> Union[List[str], Versions, ToolData]:
+    def getList(self) -> Metadata:
         return self._action()
 
     def fetch_arches(self, version: Version) -> List[str]:
@@ -589,19 +593,15 @@ class MetadataFactory:
         return arches
 
     def fetch_versions(self, extension: str = "") -> Versions:
-        def filter_by(ver_ext: Tuple[Optional[Version], str]) -> bool:
-            version, ext = ver_ext
-            return version and (self.spec is None or version in self.spec) and (ext == extension)
-
-        def get_version(ver_ext: Tuple[Version, str]):
-            return ver_ext[0]
+        def filter_by(ver: Version, ext: str) -> bool:
+            return (self.spec is None or ver in self.spec) and ext == extension
 
         versions_extensions = self.get_versions_extensions(
             self.fetch_http(self.archive_id.to_url(), False), self.archive_id.category
         )
-        versions = sorted(filter(None, map(get_version, filter(filter_by, versions_extensions))))
-        iterables = itertools.groupby(versions, lambda version: version.minor)
-        return Versions(iterables)
+        versions = sorted([ver for ver, ext in versions_extensions if ver is not None and filter_by(ver, ext)])
+        grouped = cast(Iterable[Tuple[int, Iterable[Version]]], itertools.groupby(versions, lambda version: version.minor))
+        return Versions(grouped)
 
     def fetch_latest_version(self, ext: str) -> Optional[Version]:
         return self.fetch_versions(ext).latest()
@@ -635,7 +635,7 @@ class MetadataFactory:
             return None
 
         # Remove items that don't conform to simple_spec
-        tools_versions = filter(lambda tool_item: tool_item[2] in simple_spec, tools_versions)
+        tools_versions = [tool_item for tool_item in tools_versions if tool_item[2] in simple_spec]
 
         try:
             # Return the conforming item with the highest version.
@@ -674,22 +674,25 @@ class MetadataFactory:
         timeout = (Settings.connection_timeout, Settings.response_timeout)
         expected_hash = get_hash(rest_of_url, "sha256", timeout) if is_check_hash else None
         base_urls = self.base_url, random.choice(Settings.fallbacks)
+
+        err: BaseException = AssertionError("unraisable")
+
         for i, base_url in enumerate(base_urls):
             try:
                 url = posixpath.join(base_url, rest_of_url)
                 return getUrl(url=url, timeout=timeout, expected_hash=expected_hash)
 
             except (ArchiveDownloadError, ArchiveConnectionError) as e:
-                if i == len(base_urls) - 1:
-                    raise e from e
-                else:
+                err = e
+                if i < len(base_urls) - 1:
                     getLogger("aqt.metadata").debug(
                         f"Connection to '{base_url}' failed. Retrying with fallback '{base_urls[i + 1]}'."
                     )
+        raise err from err
 
     def iterate_folders(self, html_doc: str, html_url: str, *, filter_category: str = "") -> Generator[str, None, None]:
         def link_to_folder(link: bs4.element.Tag) -> str:
-            raw_url: str = link.get("href", default="")
+            raw_url: str = str(link.get("href", default=""))
             url: ParseResult = urlparse(raw_url)
             if url.scheme or url.netloc:
                 return ""
@@ -738,7 +741,7 @@ class MetadataFactory:
         if downloads is None or update_file is None:
             return False
         uncompressed_size = int(update_file.attrib["UncompressedSize"])
-        return downloads.text and uncompressed_size >= Settings.min_module_size
+        return downloads.text is not None and uncompressed_size >= Settings.min_module_size
 
     def _get_qt_version_str(self, version: Version) -> str:
         """Returns a Qt version, without dots, that works in the Qt repo urls and Updates.xml files"""
@@ -778,13 +781,20 @@ class MetadataFactory:
                 module = module[len("addons.") :]
             return module, arch
 
-        modules = set()
+        modules: Set[str] = set()
         for name in modules_meta.keys():
             module, _arch = to_module_arch(name)
             if _arch == arch:
-                modules.add(module)
+                modules.add(cast(str, module))
         return sorted(modules)
 
+    @staticmethod
+    def require_text(element: Element, key: str) -> str:
+        node = element.find(key)
+        if node is None:
+            raise ArchiveListError(f"Downloaded metadata does not match the expected structure. Missing key: {key}")
+        return node.text or ""
+
     def fetch_long_modules(self, version: Version, arch: str) -> ModuleData:
         """Returns long listing of modules"""
         extension = QtRepoProperty.extension_for_arch(arch, version >= Version("6.0.0"))
@@ -801,11 +811,16 @@ class MetadataFactory:
         )
 
         def matches_arch(element: Element) -> bool:
-            name_node = element.find("Name")
-            return bool(name_node is not None) and bool(pattern.match(str(name_node.text)))
+            return bool(pattern.match(MetadataFactory.require_text(element, "Name")))
 
         modules_meta = self._fetch_module_metadata(self.archive_id.to_folder(qt_ver_str, extension), matches_arch)
-        m = {pattern.match(key).group("module"): value for key, value in modules_meta.items()}
+        m: Dict[str, Dict[str, str]] = {}
+        for key, value in modules_meta.items():
+            match = pattern.match(key)
+            if match is not None:
+                module = match.group("module")
+                if module is not None:
+                    m[module] = value
 
         return ModuleData(m)
 
@@ -839,23 +854,23 @@ class MetadataFactory:
         nonempty = MetadataFactory._has_nonempty_downloads
 
         def all_modules(element: Element) -> bool:
-            _module, _arch = element.find("Name").text.split(".")[-2:]
+            _module, _arch = MetadataFactory.require_text(element, "Name").split(".")[-2:]
             return _arch == arch and _module != qt_version_str and nonempty(element)
 
         def specify_modules(element: Element) -> bool:
-            _module, _arch = element.find("Name").text.split(".")[-2:]
+            _module, _arch = MetadataFactory.require_text(element, "Name").split(".")[-2:]
             return _arch == arch and _module in modules and nonempty(element)
 
         def no_modules(element: Element) -> bool:
-            name: Optional[str] = element.find("Name").text
-            return name and name.endswith(f".{qt_version_str}.{arch}") and nonempty(element)
+            name: Optional[str] = getattr(element.find("Name"), "text", None)
+            return name is not None and name.endswith(f".{qt_version_str}.{arch}") and nonempty(element)
 
         predicate = no_modules if not modules else all_modules if "all" in modules else specify_modules
         try:
             mod_metadata = self._fetch_module_metadata(
                 self.archive_id.to_folder(qt_version_str, extension), predicate=predicate
             )
-        except (AttributeError,) as e:
+        except (AttributeError, ValueError) as e:
             raise ArchiveListError(f"Downloaded metadata is corrupted. {e}") from e
 
         # Did we find all requested modules?
@@ -882,7 +897,7 @@ class MetadataFactory:
             return "gcc_64"
         elif self.archive_id.host == "mac":
             return "clang_64"
-        arches = list(filter(lambda arch: QtRepoProperty.MINGW_ARCH_PATTERN.match(arch), self.fetch_arches(version)))
+        arches = [arch for arch in self.fetch_arches(version) if QtRepoProperty.MINGW_ARCH_PATTERN.match(arch)]
         selected_arch = QtRepoProperty.select_default_mingw(arches, is_dir=False)
         if not selected_arch:
             raise EmptyMetadata("No default desktop architecture available")
diff --git a/aqt/updater.py b/aqt/updater.py
index 9667bfe..60a31c0 100644
--- a/aqt/updater.py
+++ b/aqt/updater.py
@@ -23,7 +23,7 @@ import os
 import subprocess
 from logging import getLogger
 from pathlib import Path
-from typing import Optional
+from typing import Dict, Optional
 
 import patch
 
@@ -43,8 +43,8 @@ class Updater:
     def __init__(self, prefix: Path, logger):
         self.logger = logger
         self.prefix = prefix
-        self.qmake_path = None
-        self.qconfigs = {}
+        self.qmake_path: Optional[Path] = None
+        self.qconfigs: Dict[str, str] = {}
 
     def _patch_binfile(self, file: Path, key: bytes, newpath: bytes):
         """Patch binary file with key/value"""
@@ -149,6 +149,8 @@ class Updater:
     def patch_qmake(self):
         """Patch to qmake binary"""
         if self._detect_qmake():
+            if self.qmake_path is None:
+                return
             self.logger.info("Patching {}".format(str(self.qmake_path)))
             self._patch_binfile(
                 self.qmake_path,
diff --git a/ci/generate_combinations.py b/ci/generate_combinations.py
index b29135f..2d455e9 100644
--- a/ci/generate_combinations.py
+++ b/ci/generate_combinations.py
@@ -3,6 +3,7 @@
 import argparse
 import json
 import logging
+import sys
 from pathlib import Path
 from typing import Dict, Generator, Iterable, List, Optional, Tuple, Union
 
@@ -206,6 +207,6 @@ if __name__ == "__main__":
 
     tqdm = get_tqdm(args.no_tqdm)
 
-    exit(
+    sys.exit(
         main(filename=json_filename, is_write_file=args.write, is_verbose=args.verbose)
     )
diff --git a/pyproject.toml b/pyproject.toml
index 33cd274..831e602 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -57,10 +57,11 @@ test = [
     "pympler",
 ]
 check = [
+    "mypy>=0.990",
     "flake8<6",
     "flake8-black",
     "flake8-colors",
-    "flake8-isort>=4.2.0",
+    "flake8-isort>=4.2.0,<5.0.0",
     "flake8-pyi",
     "flake8-typing-imports",
     "docutils",
@@ -121,10 +122,40 @@ use_parentheses = true
 ensure_newline_before_comments = true
 
 [tool.mypy]
-warn_redundant_casts = true
-warn_unused_ignores = true
+# Untyped definitions and calls are disable in default
+check_untyped_defs = false
+disallow_incomplete_defs = false
+disallow_untyped_calls = false
+disallow_untyped_defs = false
+disallow_untyped_decorators = false
+# not a all 3rd party package has type hints
+ignore_missing_imports = true
+# Optional check: implicit check enabled in mypy 0.980 or before
 strict_optional = true
+implicit_optional = false
+# Enable incremental mode and use sqlite cache
+incremental = true
+sqlite_cache = true
+# Enables PEP 420 style namespace packages
+namespace_packages = true
+# Control error output
+pretty = true
+color_output = true
+show_error_context = false
 show_column_numbers = true
+error_summary = true
+# configuring warnings
+warn_no_return = true
+warn_redundant_casts = true
+warn_return_any = true
+warn_unreachable = true
+warn_unused_ignores = true
+
+# TODO: Remove this `ignore_missing_imports` and add type stubs.
+# See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
+[[tool.mypy.overrides]]
+module = "texttable"
+ignore_missing_imports = true
 
 [tool.pytest.ini_options]
 minversion = "6.0"
@@ -141,7 +172,7 @@ markers = [
 [tool.tox]
 legacy_tox_ini = """
 [tox]
-envlist = check, docs, py{37,38,39,310,311}, py39d, mprof, fil
+envlist = check, docs, py{37,38,39,310,311}, py39d, mprof, fil, mypy
 isolated_build = True
 
 [testenv]
@@ -162,6 +193,15 @@ commands =
     check-manifest {toxinidir}
     flake8 aqt tests
 
+[testenv:mypy]
+basepython = python3.9
+extras = check
+commands = mypy aqt
+deps =
+    types-requests
+    types-beautifulsoup4
+    types-psutil
+
 [testenv:docs]
 basepython = python3.9
 extras = docs
@@ -206,8 +246,8 @@ commands =
 
 [gh-actions]
 python =
-    3.8: py38, docs, check
-    3.9: py39
+    3.8: py38
+    3.9: py39, docs, check, mypy
     3.10: py310
     3.11: py311
 """


Test patch:
diff --git a/.github/workflows/test-install-qt.yml b/.github/workflows/test-install-qt.yml
index b33ffbe..e882d74 100644
--- a/.github/workflows/test-install-qt.yml
+++ b/.github/workflows/test-install-qt.yml
@@ -8,12 +8,12 @@ jobs:
     strategy:
       matrix:
         os: [windows-latest, macOS-latest, ubuntu-latest]
-        py: [3.9]
+        py: ["3.10"]
         qtver: [5.9.9, 5.12.8, 6.1.0]
         artifact: [standard]
         include:
           - os: windows-latest
-            py: 3.9
+            py: "3.10"
             qtver: 6.1.0
             artifact: binary
           - os: ubuntu-20.04
@@ -22,7 +22,7 @@ jobs:
             artifact: standard
         exclude:
           - os: ubuntu-latest
-            py: 3.9
+            py: "3.10"
             qtver: 6.1.0
     steps:
       - uses: actions/checkout@v1
diff --git a/tests/test_archives.py b/tests/test_archives.py
index 66ef50f..da6a4a5 100644
--- a/tests/test_archives.py
+++ b/tests/test_archives.py
@@ -207,7 +207,10 @@ def test_tools_variants(monkeypatch, tool_name, tool_variant_name, is_expect_fai
         return
 
     expect_json = json.loads((Path(__file__).parent / "data" / f"{datafile}-expect.json").read_text("utf-8"))
-    expect = next(filter(lambda x: x["Name"] == tool_variant_name, expect_json["variants_metadata"]))
+    try:
+        expect = next(x for x in expect_json["variants_metadata"] if x["Name"] == tool_variant_name)
+    except StopIteration:
+        raise Exception("Wrong test data.")
     expected_7z_files = set(expect["DownloadableArchives"])
     qt_pkgs = ToolArchives(host, target, tool_name, base, arch=tool_variant_name).archives
     url_begin = f"online/qtsdkrepository/mac_x64/{target}/{tool_name}"
diff --git a/tests/test_helper.py b/tests/test_helper.py
index 31dc2a0..72be016 100644
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -63,6 +63,46 @@ def test_helper_altlink(monkeypatch):
     assert newurl.startswith("http://ftp.jaist.ac.jp/")
 
 
+def test_helper_altlink_black(monkeypatch):
+    class Message:
+        headers = {"content-type": "text/plain", "length": 300}
+        text = """<?xml version="1.0" encoding="UTF-8"?>
+<metalink xmlns="urn:ietf:params:xml:ns:metalink">
+  <generator>MirrorBrain/2.17.0</generator>
+  <origin dynamic="true">http://download.example.io/boo.7z.meta4</origin>
+  <published>2020-03-04T01:11:48Z</published>
+  <publisher>
+    <name>Example Project</name>
+    <url>https://download.example.io</url>
+  </publisher>
+
+  <file name="boo.7z">
+    <size>651</size>
+    <hash type="md5">d49eba3937fb063caa48769e8f28377c</hash>
+    <hash type="sha-1">25d3a33d00c1e5880679a17fd4b8b831134cfa6f</hash>
+    <hash type="sha-256">37e50248cf061109e2cb92105cd2c36a6e271701d6d4a72c4e73c6d82aad790a</hash>
+    <pieces length="262144" type="sha-1">
+      <hash>bec628a149ed24a3a9b83747776ecca5a1fad11c</hash>
+      <hash>98b1dee3f741de51167a9428b0560cd2d1f4d945</hash>
+      <hash>8717a0cb3d14c1958de5981635c9b90b146da165</hash>
+      <hash>78cd2ae3ae37ca7c080a56a2b34eb33ec44a9ef1</hash>
+    </pieces>
+    <url location="cn" priority="1">http://mirrors.geekpie.club/boo.7z</url>
+  </file>
+</metalink>
+"""
+
+    def mock_return(url):
+        return Message()
+
+    monkeypatch.setattr(helper, "_get_meta", mock_return)
+
+    url = "http://foo.baz/qtproject/boo.7z"
+    alt = "http://ftp.yz.yamagata-u.ac.jp/pub/boo.7z"
+    newurl = helper.altlink(url, alt)
+    assert newurl.startswith("http://ftp.yz.yamagata-u.ac.jp/pub/boo.7z")
+
+
 @pytest.mark.load_default_settings(False)
 def test_settings(tmp_path):
     helper.Settings.load_settings(os.path.join(os.path.dirname(__file__), "data", "settings.ini"))
diff --git a/tests/test_install.py b/tests/test_install.py
index f98d362..bd31df4 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -77,7 +77,7 @@ LIST_OF_FILES_AND_CONTENTS = Iterable[Dict[str, str]]
 class PatchedFile:
     filename: str
     unpatched_content: str
-    patched_content: Optional[str]
+    patched_content: Optional[str] = None  # When None, the file is expected not to be patched.
 
     def expected_content(self, **kwargs) -> str:
         if not self.patched_content:
@@ -349,6 +349,41 @@ def tool_archive(host: str, tool_name: str, variant: str, date: datetime = datet
                 r"INFO    : Time elapsed: .* second"
             ),
         ),
+        (  # Mixing --modules with --archives
+            "install-qt windows desktop 5.14.0 win32_mingw73 -m qtcharts --archives qtbase".split(),
+            "windows",
+            "desktop",
+            "5.14.0",
+            {"std": "win32_mingw73"},
+            {"std": "mingw73_32"},
+            {"std": "windows_x86/desktop/qt5_5140/Updates.xml"},
+            {
+                "std": [
+                    plain_qtbase_archive("qt.qt5.5140.win32_mingw73", "win32_mingw73"),
+                    MockArchive(
+                        filename_7z="qtcharts-windows-win32_mingw73.7z",
+                        update_xml_name="qt.qt5.5140.qtcharts.win32_mingw73",
+                        contents=(PatchedFile(filename="lib/qtcharts.h", unpatched_content="... charts ...\n"),),
+                        should_install=True,
+                    ),
+                    MockArchive(
+                        filename_7z="qtlottie-windows-win32_mingw73.7z",
+                        update_xml_name="qt.qt5.5140.qtlottie.win32_mingw73",
+                        contents=(PatchedFile(filename="lib/qtlottie.h", unpatched_content="... lottie ...\n"),),
+                        should_install=False,
+                    ),
+                ]
+            },
+            re.compile(
+                r"^INFO    : aqtinstall\(aqt\) v.* on Python 3.*\n"
+                r"INFO    : Downloading qtbase...\n"
+                r"Finished installation of qtbase-windows-win32_mingw73.7z in .*\n"
+                r"INFO    : Downloading qtcharts...\n"
+                r"Finished installation of qtcharts-windows-win32_mingw73.7z in .*\n"
+                r"INFO    : Finished installation\n"
+                r"INFO    : Time elapsed: .* second"
+            ),
+        ),
         (
             "install-src windows desktop 5.14.2".split(),
             "windows",
diff --git a/tests/test_list.py b/tests/test_list.py
index 0d74c64..9877abd 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -33,6 +33,8 @@ from aqt.metadata import (
     suggested_follow_up,
 )
 
+ModulesQuery = MetadataFactory.ModulesQuery
+
 Settings.load_settings()
 
 
@@ -308,7 +310,7 @@ def test_list_archives(
     else:
         expected_mod_metadata = expect["modules_metadata_by_arch"][arch]
         if "all" not in modules_to_query:
-            expected_mod_metadata = filter(lambda mod: mod["Name"].split(".")[-2] in modules_to_query, expected_mod_metadata)
+            expected_mod_metadata = [mod for mod in expected_mod_metadata if mod["Name"].split(".")[-2] in modules_to_query]
         expected = set([arc.split("-")[0] for mod in expected_mod_metadata for arc in mod["DownloadableArchives"]])
 
     archives_query = [version, arch, *modules_to_query]
@@ -342,19 +344,22 @@ def test_list_archives_insufficient_args(capsys):
     assert err.strip() == "ERROR   : The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter."
 
 
-def test_list_archives_bad_xml(monkeypatch):
+@pytest.mark.parametrize(
+    "xml_content",
+    (
+        "<Updates><PackageUpdate><badname></badname></PackageUpdate></Updates>",
+        "<Updates><PackageUpdate><Name></Name></PackageUpdate></Updates>",
+        "<Updates></PackageUpdate><PackageUpdate></Updates><Name></Name>",
+    ),
+)
+def test_list_archives_bad_xml(monkeypatch, xml_content: str):
     archive_id = ArchiveId("qt", "windows", "desktop")
     archives_query = ["5.15.2", "win32_mingw81", "qtcharts"]
 
-    xml_no_name = "<Updates><PackageUpdate><badname></badname></PackageUpdate></Updates>"
-    xml_empty_name = "<Updates><PackageUpdate><Name></Name></PackageUpdate></Updates>"
-    xml_broken = "<Updates></PackageUpdate><PackageUpdate></Updates><Name></Name>"
-
-    for _xml in (xml_no_name, xml_empty_name, xml_broken):
-        monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
-        with pytest.raises(ArchiveListError) as e:
-            MetadataFactory(archive_id, archives_query=archives_query).getList()
-        assert e.type == ArchiveListError
+    monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: xml_content)
+    with pytest.raises(ArchiveListError) as e:
+        MetadataFactory(archive_id, archives_query=archives_query).getList()
+    assert e.type == ArchiveListError
 
 
 @pytest.mark.parametrize(
@@ -397,7 +402,7 @@ def test_long_qt_modules(monkeypatch, host: str, target: str, version: str, arch
 
     monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
 
-    table = MetadataFactory(archive_id, modules_query=(version, arch), is_long_listing=True).getList()
+    table = MetadataFactory(archive_id, modules_query=ModulesQuery(version, arch), is_long_listing=True).getList()
     assert table._rows(table.long_heading_keys) == expect["modules_long_by_arch"][arch]
 
 
@@ -645,7 +650,7 @@ wrong_arch_msg = "Please use 'aqt list-qt mac desktop --arch <QT_VERSION>' to li
             [wrong_qt_version_msg],
         ),
         (
-            MetadataFactory(mac_qt, modules_query=("1.2.3", "clang_64")),
+            MetadataFactory(mac_qt, modules_query=ModulesQuery("1.2.3", "clang_64")),
             [wrong_qt_version_msg, wrong_arch_msg],
         ),
         (
@@ -676,7 +681,7 @@ wrong_arch_msg = "Please use 'aqt list-qt mac desktop --arch <QT_VERSION>' to li
             [wrong_qt_version_msg],
         ),
         (
-            MetadataFactory(mac_qt, modules_query=("1.2.3", "clang_64")),
+            MetadataFactory(mac_qt, modules_query=ModulesQuery("1.2.3", "clang_64")),
             [wrong_qt_version_msg, wrong_arch_msg],
         ),
     ),
@@ -860,65 +865,64 @@ def test_show_list_tools_long_ifw(capsys, monkeypatch, columns, expect):
     assert out == expect
 
 
+LONG_MODULES_WIN_5140_120 = (
+    "   Module Name                        Display Name                     Release Date   Download Size   Installed Size\n"
+    "====================================================================================================================\n"
+    "qtcharts            Qt Charts for MinGW 7.3.0 32-bit                   2019-12-11     772.7K          7.8M          \n"
+    "qtdatavis3d         Qt Data Visualization for MinGW 7.3.0 32-bit       2019-12-11     620.2K          5.0M          \n"
+    "qtlottie            Qt Lottie Animation for MinGW 7.3.0 32-bit         2019-12-11     153.5K          968.4K        \n"
+    "qtnetworkauth       Qt Network Authorization for MinGW 7.3.0 32-bit    2019-12-11     98.7K           638.6K        \n"
+    "qtpurchasing        Qt Purchasing for MinGW 7.3.0 32-bit               2019-12-11     50.9K           306.8K        \n"
+    "qtquick3d           Qt Quick 3D for MinGW 7.3.0 32-bit                 2019-12-11     9.8M            21.2M         \n"
+    "qtquicktimeline     Qt Quick Timeline for MinGW 7.3.0 32-bit           2019-12-11     35.3K           154.1K        \n"
+    "qtscript            Qt Script for MinGW 7.3.0 32-bit                   2019-12-11     1.0M            5.5M          \n"
+    "qtvirtualkeyboard   Qt Virtual Keyboard for MinGW 7.3.0 32-bit         2019-12-11     2.1M            6.8M          \n"
+    "qtwebglplugin       Qt WebGL Streaming Plugin for MinGW 7.3.0 32-bit   2019-12-11     201.7K          1.0M          \n"
+)
+LONG_MODULES_WIN_5140_80 = (
+    "   Module Name                        Display Name                  \n"
+    "====================================================================\n"
+    "qtcharts            Qt Charts for MinGW 7.3.0 32-bit                \n"
+    "qtdatavis3d         Qt Data Visualization for MinGW 7.3.0 32-bit    \n"
+    "qtlottie            Qt Lottie Animation for MinGW 7.3.0 32-bit      \n"
+    "qtnetworkauth       Qt Network Authorization for MinGW 7.3.0 32-bit \n"
+    "qtpurchasing        Qt Purchasing for MinGW 7.3.0 32-bit            \n"
+    "qtquick3d           Qt Quick 3D for MinGW 7.3.0 32-bit              \n"
+    "qtquicktimeline     Qt Quick Timeline for MinGW 7.3.0 32-bit        \n"
+    "qtscript            Qt Script for MinGW 7.3.0 32-bit                \n"
+    "qtvirtualkeyboard   Qt Virtual Keyboard for MinGW 7.3.0 32-bit      \n"
+    "qtwebglplugin       Qt WebGL Streaming Plugin for MinGW 7.3.0 32-bit\n"
+)
+
+
 @pytest.mark.parametrize(
-    "columns, expect",
+    "columns, use_cli, expect",
     (
-        (
-            120,
-            "   Module Name                        Display Name                     Release Date   Download Size   "
-            "Installed Size\n"
-            "======================================================================================================"
-            "==============\n"
-            "qtcharts            Qt Charts for MinGW 7.3.0 32-bit                   2019-12-11     772.7K          "
-            "7.8M          \n"
-            "qtdatavis3d         Qt Data Visualization for MinGW 7.3.0 32-bit       2019-12-11     620.2K          "
-            "5.0M          \n"
-            "qtlottie            Qt Lottie Animation for MinGW 7.3.0 32-bit         2019-12-11     153.5K          "
-            "968.4K        \n"
-            "qtnetworkauth       Qt Network Authorization for MinGW 7.3.0 32-bit    2019-12-11     98.7K           "
-            "638.6K        \n"
-            "qtpurchasing        Qt Purchasing for MinGW 7.3.0 32-bit               2019-12-11     50.9K           "
-            "306.8K        \n"
-            "qtquick3d           Qt Quick 3D for MinGW 7.3.0 32-bit                 2019-12-11     9.8M            "
-            "21.2M         \n"
-            "qtquicktimeline     Qt Quick Timeline for MinGW 7.3.0 32-bit           2019-12-11     35.3K           "
-            "154.1K        \n"
-            "qtscript            Qt Script for MinGW 7.3.0 32-bit                   2019-12-11     1.0M            "
-            "5.5M          \n"
-            "qtvirtualkeyboard   Qt Virtual Keyboard for MinGW 7.3.0 32-bit         2019-12-11     2.1M            "
-            "6.8M          \n"
-            "qtwebglplugin       Qt WebGL Streaming Plugin for MinGW 7.3.0 32-bit   2019-12-11     201.7K          "
-            "1.0M          \n",
-        ),
-        (
-            80,
-            "   Module Name                        Display Name                  \n"
-            "====================================================================\n"
-            "qtcharts            Qt Charts for MinGW 7.3.0 32-bit                \n"
-            "qtdatavis3d         Qt Data Visualization for MinGW 7.3.0 32-bit    \n"
-            "qtlottie            Qt Lottie Animation for MinGW 7.3.0 32-bit      \n"
-            "qtnetworkauth       Qt Network Authorization for MinGW 7.3.0 32-bit \n"
-            "qtpurchasing        Qt Purchasing for MinGW 7.3.0 32-bit            \n"
-            "qtquick3d           Qt Quick 3D for MinGW 7.3.0 32-bit              \n"
-            "qtquicktimeline     Qt Quick Timeline for MinGW 7.3.0 32-bit        \n"
-            "qtscript            Qt Script for MinGW 7.3.0 32-bit                \n"
-            "qtvirtualkeyboard   Qt Virtual Keyboard for MinGW 7.3.0 32-bit      \n"
-            "qtwebglplugin       Qt WebGL Streaming Plugin for MinGW 7.3.0 32-bit\n",
-        ),
+        (120, False, LONG_MODULES_WIN_5140_120),
+        (80, False, LONG_MODULES_WIN_5140_80),
+        (120, True, LONG_MODULES_WIN_5140_120),
+        (80, True, LONG_MODULES_WIN_5140_80),
     ),
 )
-def test_show_list_long_qt_modules(capsys, monkeypatch, columns, expect):
+def test_show_list_long_qt_modules(capsys, monkeypatch, columns: int, use_cli: bool, expect: str):
     update_xml = (Path(__file__).parent / "data" / "windows-5140-update.xml").read_text("utf-8")
     monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: update_xml)
 
+    cli = Cli()
+
+    # Patching get_terminal_size prevents successful instantiation of Cli, so do it afterwards:
     monkeypatch.setattr(shutil, "get_terminal_size", lambda fallback: os.terminal_size((columns, 24)))
 
-    meta = MetadataFactory(
-        ArchiveId("qt", "windows", "desktop"),
-        modules_query=("5.14.0", "win32_mingw73"),
-        is_long_listing=True,
-    )
-    show_list(meta)
+    if use_cli:
+        return_code = cli.run("list-qt windows desktop --long-modules 5.14.0 win32_mingw73".split())
+        assert return_code == 0
+    else:
+        meta = MetadataFactory(
+            ArchiveId("qt", "windows", "desktop"),
+            modules_query=ModulesQuery("5.14.0", "win32_mingw73"),
+            is_long_listing=True,
+        )
+        show_list(meta)
     out, err = capsys.readouterr()
     sys.stdout.write(out)
     sys.stderr.write(err)