Repository: miurahr/aqtinstall

Issue:
`Cli.run` return value / error handling strategy
Here's the code for `Cli.run`:

https://github.com/miurahr/aqtinstall/blob/179a9c68f948562e9768fceba36f8457bedd6845/aqt/installer.py#L86-L90

The return value from this function is eventually returned to the command prompt by an earlier `main` function call. By reading this code, you would expect that the set of functions that can be assigned to `args.func`, aka `run_install_src`, `run_install_doc`, `run_install_tool`, `run_install_qt`, `run_list_qt`, and `run_list_tool`, would all return a `1` or a `0`, or some other numeric value. However, they do not. The `run_install_*` functions all return `None`, which is coerced into a `0`, and whenever an error occurs, the function calls `self.logger.error(some_error_message)` followed by `exit(1)`.  Several instances of `exit(1)` are not preceded by an error message, so it's unclear whether the user is being told what the problem is, or that there even is a problem. There are multiple copies of something like this:

https://github.com/miurahr/aqtinstall/blob/179a9c68f948562e9768fceba36f8457bedd6845/aqt/installer.py#L249-L251

There's a lot of inconsistently repeated code here.

This is the design that I would have expected for `Cli.run()`:
```python
def run(self, arg=None):
  args = self.parser.parse_args(arg)
  self._setup_settings(args)
  try:
    args.func(args)
    return 0
  except SomeExpectedBaseException as e:
    self.logger.error(format(e))
    return 1
```

All of the `run_install_*` or `run_list_*` functions would return `None`. If any errors occur that cannot be recovered from, they would raise an exception that contains a useful error message, so that `Cli.run` can log it. None of these functions would attempt to catch exceptions unless they are recoverable, or the error message needs to be modified in some way. Additionally, any time an exception is raised elsewhere in the program, that exception would contain whatever error message is appropriate to show the user what went wrong, instead of logging the message and raising an empty exception.

There's one disadvantage of this approach: Currently, when exceptions are raised, the code that raises the exception also logs the error message using its own logger, which means the name of the logger is attached to the log message, so you can tell what file it's coming from. If that data is really important, we could use `self.logger.exception(e)` in `Cli.run` instead of `self.logger.error(e)`; this would attach a stack trace to the log, which would include filenames and line numbers.

Please let me know if you think this kind of thing would be an improvement. I think it would be relatively easy to implement this change, it would remove some repeated code, and I think it would make the project easier to maintain. However, that's just my opinion; this kind of refactor is very subjective, and I totally understand if you think I'm trying to fix something that isn't really broken. For that reason, I won't start on this unless you think it's a good idea.

Discussion:

Patch:
diff --git a/.readthedocs.yml b/.readthedocs.yml
deleted file mode 100644
index 976fca0..0000000
--- a/.readthedocs.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-version: 2
-
-sphinx:
-  configuration: docs/conf.py
-
-formats:
-  - epub
-  - pdf
-
-python:
-   version: 3.7
-   install:
-      - method: setuptools
-        path: .
-      - method: pip
-        path: .
-        extra_requirements:
-           - docs
-   system_packages: false
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ec9944f..92f2cfe 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,7 +19,6 @@ Added
 * Automatically update combinations.json (#343,#344,#345)
 * CI: test with Qt6.2 with modules(#346)
 * README: link documentation for stable(#329)
-* Support WASM on Qt 6.2.0(#384)
 
 Changed
 -------
@@ -34,7 +33,7 @@ Changed
 * tool subcommand now install without version spec(#299)
 * README example command is now easy to copy-and-paste(#322)  
 * list subcommand update(#331)
-* Improve handle of Ctrl-C keyboard interruption(#337)
+* Improve handle of Ctrl-C keyboard interrption(#337)
 * Update combinations.json(#344)
 * Turn warnings into errors when building docs(#360)
 * Update documentations(#358,#357)
@@ -45,7 +44,6 @@ Changed
 * Failed on missing tools(#375)
 * Remove 'addons' prefix for some modules for Qt6+ (#368)
 * Fix inappropriate warnings(#370)
-* Update README to fix version 2 (#377)
 
 Fixed
 -----
@@ -91,7 +89,7 @@ Added
 * Document for settings.ini configuration parameters(#246)
 * Patching libtool file(.la) on mac(#267)
 * CI: Add more blacklist mirrors
-* Add --kde option for src subcommand(#274)
+* Add --kde option for src subommand(#274)
 
 Changed
 -------
diff --git a/MANIFEST.in b/MANIFEST.in
index 23263f6..fa83610 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -16,7 +16,6 @@ recursive-include tests *.py
 recursive-include tests *.xml
 prune .github
 exclude .gitignore
-exclude .readthedocs.yml
 exclude azure-pipelines.yml
 exclude qtaccount.ini
 exclude aqtinstall.log
diff --git a/README.rst b/README.rst
index 3ff387d..e8524b7 100644
--- a/README.rst
+++ b/README.rst
@@ -14,19 +14,38 @@ Another Qt installer(aqt)
 .. |coveralls| image:: https://coveralls.io/repos/github/miurahr/aqtinstall/badge.svg?branch=master
    :target: https://coveralls.io/github/miurahr/aqtinstall?branch=master
 
-This is a utility alternative to the official graphical Qt installer, for using in CI environment
-where an interactive UI is not usable, or just on command line.
+This is a utility alternative to the official graphical Qt installer, for using in CI environment where an interactive
+UI is not usable such as Github Actions, Travis-CI, CircleCI, Azure-Pipelines, AppVeyor and others.
 
-It can automatically download prebuilt Qt binaries, documents and sources for target specified,
-when the versions are on Qt download mirror sites.
+.. warning::
+    This is NOT franchised with The Qt Company and The Qt Project.
+    there is NO guarantee and support. Please don't ask them about aqtinstall.
+
+    When you need official and/or commercial support about unattended install,
+    please ask your Qt reseller, or help desk according to your contract.
+
+    The official installer has a capability to scripting installation process,
+    please ask a consult with `the official documents`_.
+
+
+.. _`the official documents`: https://doc.qt.io/qtinstallerframework/ifw-use-cases-cli.html#unattended-usage
+
+
+It can automatically download prebuilt Qt binaries for any target (you're not bound to
+Linux binaries on Linux; you could also download iOS binaries).
+It's working with Python >= 3.6 on Linux, macOS and Windows.
+
+When installing QtBase package on proper platforms (eg. install linux package on linux),
+aqt update Qt binaries(eg. qmake, and libQt5Core.so/Qt5Core.dll/Framework.QtCore for Qt<5.14),
+and change configurations(eg. qt.conf, and qconfig.pri) to make it working well with installed directory(Qt prefix).
+
+The aqtinstall does not update PATH environment variable.
 
 .. note::
     Because it is an installer utility, it can download from Qt distribution site and its mirror.
     The site is operated by The Qt Company who may remove versions you may want to use that become end of support.
-    Please don't blame us.
-
-.. warning::
-    This is NOT franchised with The Qt Company and The Qt Project. Please don't ask them about aqtinstall.
+    Please don't blame us. When you keep your old mirror archives and operate an archive site,
+    you may be able to use aqtinstall with base URL option specified to your site.
 
 
 License and copyright
@@ -43,45 +62,28 @@ For details see `Qt licensing`_ and `Licenses used in Qt5`_
 
 .. _`Licenses used in Qt5`: https://doc.qt.io/qt-5/licenses-used-in-qt.html
 
-
 Requirements
 ------------
 
-- Minimum Python version:
-    3.6
-
-- Recommended Python version:
-    3.9 (frequently tested on)
-
-- Dependencies:
-    requests
-    semantic_version
-    patch
-    py7zr
-    texttable
-    bs4
-    dataclasses
-
-- Operating Systems:
-    Linux, macOS, MS Windows
+- Minimum Python version:  3.6
+- Recommended Python version: 3.8, 3.9 (frequently tested on)
 
+- Dependent libraries: requests, py7zr
 
 Documentation
 -------------
 
-There is precise documentation with many examples.
-You are recommended to read the *Getting started* section.
+There are two versions of documentation:
+
+- Stable version (v1.2) : https://aqtinstall.readthedocs.io/en/stable/
+- Development version (v2.0b): https://aqtinstall.readthedocs.io/en/latest
 
-- Getting started: https://aqtinstall.readthedocs.io/en/latest/getting_started.html
-- Stable (v2.0.x): https://aqtinstall.readthedocs.io/en/stable
-- Latest: https://aqtinstall.readthedocs.io/en/latest
 
-- Old (v1.2.5) : https://aqtinstall.readthedocs.io/en/v1.2.5/index.html
 
 Install
 -------
 
-Same as usual, it can be installed with ``pip``:
+Same as usual, it can be installed with `pip`
 
 .. code-block:: console
 
@@ -96,50 +98,227 @@ You are recommended to update pip before installing aqtinstall.
     Older pip does not handle it expectedly(see #230).
 
 
-Example
---------
+Usage(as in v1.2.x)
+-------------------
+
+General usage looks like this:
+
+::
+
+    aqt [-h][--help][-O | --outputdir <directory>][-b | --base <mirror url>][-E | --external <7zip command>] \
+        install <qt-version> <host> <target> [<arch>] [-m all | -m [extra module] [extra module]...] [--internal]
+        [--archives <archive>[ <archive>...]] [--timeout <timeout(sec)>]
+
+You can also call with ``python -m aqt`` syntax as well as command script ``aqt``.
+
+Many older Linux distributions symlink ``python`` to the older Python 2.7, which is incompatible with ``aqt``.
+If you are using such a system, please check your Python 3 version with ``python3 --version``, 
+and run ``aqt`` using ``python3 -m aqt``.
+
+* The Qt version is formatted like this: `5.11.3`
+* Host is one of: `linux`, `mac`, `windows`
+* Target is one of: `desktop`, `android`, `ios` (iOS only works with mac host)
+* For some platforms you also need to specify an arch:
+    * For windows, choose one of:
+        * `win64_msvc2019_64`, `win32_msvc2019`,
+        * `win64_msvc2017_64`, `win32_msvc2017`,
+        * `win64_msvc2015_64`, `win32_msvc2015`,
+        * `win64_mingw81`, `win32_mingw81`,
+        * `win64_mingw73`, `win32_mingw73`,
+        * `win64_mingw53`, `win32_mingw53`,
+        * `win64_msvc2019_winrt_x64`, `win64_msvc2019_winrt_x86`, `win64_msvc2019_winrt_armv7`
+        * `win64_msvc2017_winrt_x64`, `win64_msvc2017_winrt_x86`, `win64_msvc2017_winrt_armv7`
+    * For android and Qt 6 or Qt 5.13 and below, choose one of: `android_x86_64`, `android_arm64_v8a`, `android_x86`,
+      `android_armv7`
+* You can specify external 7zip command path instead of built-in extractor.
+* When specifying `all` for extra modules option `-m` all extra modules are installed.
+
+
+Installing tool and utility (Experimental)
+------------------------------------------
+
+You can install tools and utilities using following syntax;
+
+::
 
-When installing Qt SDK 6.2.0 for Windows.
+    aqt [-h][--help][-O | --outputdir <directory>][-b | --base <mirror url>][-E <7zip command>] \
+        tool <host> <tool_name> <tool-version> <arch> [--timeout <timeout>]
 
-Check the options that can be used with the ``list-qt`` subcommand, and query available architectures:
+* tool_name is one of `tools_ifw`, `tools_vcredist`, and `tools_openssl`.
+* arch is full qualified tool name such as `qt.tools.ifw.31` which values can be seen on Qt `archive_site`_
+  This is a quite experimental feature, may not work and please use it with your understanding what you are doing.
+* It does not recognize 'installscript.qs'.
+  When using tools which depends on a qt script, you should do something by yourself.
+
+.. _`archive_site`: https://download.qt.io/online/qtsdkrepository/linux_x64/desktop/tools_ifw/
+
+
+Target directory
+----------------
+
+aqt can take option '--outputdir' or '-O' that specify a target directory.
+
+The Qt packages are installed under current directory as such `Qt/<ver>/gcc_64/`
+
+If you want to install it in `C:\Qt` as same as standard gui installer default,
+run such as follows(on Windows):
 
 .. code-block:: console
 
-    aqt list-qt windows desktop --arch 6.2.0
+    cd c:\
+    mkdir Qt
+    py -m aqt install --outputdir c:\Qt 5.15.2 windows desktop win64_msvc2019_64
+
 
-Then you may get candidates: ``win64_mingw81 win64_msvc2019_64 win64_msvc2019_arm64``. You can also query the available modules:
+If you want to install it in `/opt/Qt` as same as standard gui installer default,
+run such as follows(on mac/linux):
 
 .. code-block:: console
 
-    aqt list-qt windows desktop --modules 6.2.0
+    sudo mkdir /opt/Qt
+    sudo python -m aqt install --outputdir /opt/Qt 5.15.2 linux desktop gcc_64
 
 
-When you decide to install Qt SDK version 6.2.0 for mingw v8.1:
+Command examples
+----------------
+
+Example: Installing Qt SDK 5.12.0 for Linux with QtCharts and QtNetworkAuth:
 
 .. code-block:: console
 
-    aqt install-qt windows desktop 6.2.0 win64_mingw81 -m all
- 
-The optional `-m all` argument installs all the modules available for Qt 6.2.0; you can leave it off if you don't want those modules.
+    pip install aqtinstall
+    aqt install --outputdir /opt/Qt 5.12.0 linux desktop -m qtcharts qtnetworkauth
+
 
-To install Qt 6.2.0 with the modules 'qtcharts' and 'qtnetworking', you can use this command (note that the module names are lowercase):
+Example: Installing Android (armv7) Qt 5.10.2:
 
 .. code-block:: console
 
-    aqt install-qt windows desktop 6.2.0 win64_mingw81 -m qtcharts qtnetworking
+    aqt install 5.10.2 linux android android_armv7
 
 
-When aqtinstall downloads and installs packages, it updates package configurations
-such as prefix directory in ``bin/qt.conf``, and ``bin/qconfig.pri``
-to make it working well with installed directory.
+Example: Installing Android Qt 5.15.2:
+
+.. code-block:: console
+
+    aqt install 5.15.2 linux android android
+
+
+Example: Install examples, doc and source:
+
+.. code-block:: console
+
+    py -m aqt examples 5.15.0 windows desktop -m qtcharts qtnetworkauth
+    py -m aqt doc 5.15.0 windows desktop -m qtcharts qtnetworkauth
+    py -m aqt src 5.15.0 windows desktop
+
+
+Example: Install Web Assembly for Qt5
+
+.. code-block:: console
+
+    aqt install 5.15.0 linux desktop wasm_32
+
+
+Example: Install an Install FrameWork (IFW):
+
+.. code-block:: console
+
+    aqt tool linux desktop tools_ifw
+
+
+Example: Install vcredist:
+
+.. code-block:: console
+
+    py -m aqt tool windows desktop tools_vcredist
+    .\Qt\Tools\vcredist\vcredist_msvc2019_x64.exe /norestart /q
+
+
+Example: Install MinGW on Windows
+
+.. code-block:: console
+
+    py -m aqt tool -O c:\Qt windows desktop tools_mingw qt.tools.win64_mingw810
+    set PATH=C:\Qt\Tools\mingw810_64\bin
+
+
+Example: Install Qt6 for android
+
+.. code-block:: console
+
+    aqt install -O qt 6.1.0 linux desktop
+    aqt install -O qt 6.1.0 linux android android_armv7
+    qt/6.1.0/android_armv7/bin/qmake -query
+
+
+Example: Install Qt6 for ios/mac
+
+.. code-block:: console
+
+    aqt install -O qt 6.1.0 mac desktop
+    aqt install -O qt 6.1.0 mac ios ios
+    qt/6.1.0/ios/bin/qmake -query
+
+
+Example: Show help message
+
+.. code-block:: console
+
+    aqt help
+
+
+Environment Variables
+---------------------
+
+It is users task to set some environment variables to fit your platform such as
+
+
+.. code-block:: bash
+
+   export PATH=/path/to/qt/x.x.x/clang_64/bin/:$PATH
+   export QT_PLUGIN_PATH=/path/to/qt/x.x.x/clang_64/plugins/
+   export QML_IMPORT_PATH=/path/to/qt/x.x.x/clang_64/qml/
+   export QML2_IMPORT_PATH=/path/to/qt/x.x.x/clang_64/qml/
+
+aqtinstall never do it for you because not to break multiple installation versions.
+
+
+
+Supported CI platforms
+----------------------
+
+There are no limitation for CI platform but currently it is tested on Azure Pipelines and Github actions.
+If you want to use it with Github actions, `install_qt`_ action will help you.
+If you want to use it with Azure Pipelines, blog article `Using Azure DevOps Pipelines with Qt`_ may be informative.
+
+
+(Advanced) Force dependency
+---------------------------
+
+(Here is a note for advanced user who knows python/pip well.)
+
+When you have a trouble on your (minor) platform to install aqtinstall's dependency,
+you can force dependencies and its versions (not recommended for ordinary use).
+You can run `pip` to install individual dependencies in manual and install aqtinstall with `--no-deps`.
+
+Example:
+^^^^^^^^
+
+Avoid installation of py7zr, python 7zip library, and force using external 7z command to extract archives.
+
+.. code-block:: console
+
+    pip install -U pip
+    pip install requests==2.25.1 semantic_version texttable
+    pip install --no-deps aqtinstall
+    python -m aqt --external /usr/local/bin/7z install 5.15.2 linux desktop
 
-.. note::
-   It is your own task to set some environment variables to fit your platform, such as PATH, QT_PLUGIN_PATH, QML_IMPORT_PATH, and QML2_IMPORT_PATH. aqtinstall will never do it for you, in order not to break the installation of multiple versions.
 
 Testimonies
 -----------
 
-Some projects utilize aqtinstall, and there are several articles and discussions
+Some projects utilize aqtinstall.
 
 * GitHub Actions: `install_qt`_
 
@@ -153,6 +332,8 @@ Some projects utilize aqtinstall, and there are several articles and discussions
 .. _`YACReader`: https://github.com/YACReader/yacreader
 
 
+Media, slide, articles and discussions
+--------------------------------------
 
 * Contributor Nelson's blog article: `Fast and lightweight headless Qt Installer from Qt Mirrors - aqtinstall`_
 
@@ -162,9 +343,7 @@ Some projects utilize aqtinstall, and there are several articles and discussions
 
 * Qt Forum: `Automatic installation for Travis CI (or any other CI)`_
 
-* Qt Forum: `Qt silent, unattended install`_
-
-* Reddit: `Qt Maintenance tool now requires you to enter your company name`_
+* Qt Form: `Qt silent, unattended install`_
 
 * Qt Study group presentation: `Another Qt CLI installer`_
 
@@ -174,7 +353,6 @@ Some projects utilize aqtinstall, and there are several articles and discussions
 .. _`Using Azure CI for cross-platform Linux and Windows Qt application builds`: https://www.wincak.name/programming/using-azure-ci-for-cross-platform-linux-and-windows-qt-application-builds/
 .. _`Automatic installation for Travis CI (or any other CI)`: https://forum.qt.io/topic/114520/automatic-installation-for-travis-ci-or-any-other-ci/2
 .. _`Qt silent, unattended install`: https://forum.qt.io/topic/122185/qt-silent-unattended-install
-.. _`Qt Maintenance tool now requires you to enter your company name`: https://www.reddit.com/r/QtFramework/comments/grgrux/qt_maintenance_tool_now_requires_you_to_enter/
 .. _`Another Qt CLI installer`: https://www.slideshare.net/miurahr-nttdata/aqt-install-for-qt-tokyo-r-2-20196
 
 
@@ -182,6 +360,7 @@ History
 -------
 
 This program is originally shown in Kaidan project as a name `qli-installer`_.
-The ``aqtinstall`` project extend and improve it.
+A project `aqtinstall` extend the original to run with standard python features with Linux, Mac and Windows,
+to be tested on CI platform, and to improve performance with a concurrent downloading.
 
 .. _`qli-installer`: https://lnj.gitlab.io/post/qli-installer
diff --git a/SECURITY.rst b/SECURITY.rst
index 7f6616b..4d78387 100644
--- a/SECURITY.rst
+++ b/SECURITY.rst
@@ -4,15 +4,15 @@ Security Policy
 Supported Versions
 ------------------
 
-+---------+---------------------+
-| Version | Status              |
-+=========+=====================+
-| 2.0.x   | Stable version      |
-+---------+---------------------+
-| 1.2.5+  | Security fixes only |
-+---------+---------------------+
-| < 1.2   | not supported       |
-+---------+---------------------+
++---------+--------------------+
+| Version | Status             |
++=========+====================+
+| 2.0.x   | Development        |
++---------+--------------------+
+| 1.2.x   | Stable version     |
++---------+--------------------+
+| < 1.2   | not supported      |
++---------+--------------------+
 
 Reporting a Vulnerability
 -------------------------
diff --git a/aqt/archives.py b/aqt/archives.py
index 16d875f..5a0705e 100644
--- a/aqt/archives.py
+++ b/aqt/archives.py
@@ -212,8 +212,7 @@ class QtArchives:
         try:
             self.update_xml = ElementTree.fromstring(self.update_xml_text)
         except ElementTree.ParseError as perror:
-            self.logger.error("Downloaded metadata is corrupted. {}".format(perror))
-            raise ArchiveListError("Downloaded metadata is corrupted.")
+            raise ArchiveListError(f"Downloaded metadata is corrupted. {perror}") from perror
 
         for packageupdate in self.update_xml.iter("PackageUpdate"):
             pkg_name = packageupdate.find("Name").text
@@ -262,7 +261,6 @@ class QtArchives:
         # if we have located every requested package, then target_packages will be empty
         if len(target_packages) > 0:
             message = f"The packages {target_packages} were not found while parsing XML of package information!"
-            self.logger.error(message)
             raise NoPackageFound(message)
 
     def get_packages(self) -> List[QtPackage]:
@@ -388,8 +386,7 @@ class ToolArchives(QtArchives):
         try:
             self.update_xml = ElementTree.fromstring(self.update_xml_text)
         except ElementTree.ParseError as perror:
-            self.logger.error("Downloaded metadata is corrupted. {}".format(perror))
-            raise ArchiveListError("Downloaded metadata is corrupted.")
+            raise ArchiveListError(f"Downloaded metadata is corrupted. {perror}") from perror
 
         try:
             packageupdate = next(filter(lambda x: x.find("Name").text == self.arch, self.update_xml.iter("PackageUpdate")))
diff --git a/aqt/exceptions.py b/aqt/exceptions.py
index 6fcea77..5774396 100644
--- a/aqt/exceptions.py
+++ b/aqt/exceptions.py
@@ -18,23 +18,58 @@
 # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
 # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
 # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+from typing import Iterable
 
 
-class ArchiveDownloadError(Exception):
+class AqtException(Exception):
+    def __init__(self, *args, **kwargs):
+        self.suggested_action: Iterable[str] = kwargs.pop("suggested_action", [])
+        self.should_show_help: bool = kwargs.pop("should_show_help", False)
+        super(AqtException, self).__init__(*args, **kwargs)
+
+    def __format__(self, format_spec) -> str:
+        base_msg = "{}".format(super(AqtException, self).__format__(format_spec))
+        if not self.suggested_action:
+            return base_msg
+        return f"{base_msg}\n{self._format_suggested_follow_up()}"
+
+    def _format_suggested_follow_up(self) -> str:
+        return ("=" * 30 + "Suggested follow-up:" + "=" * 30 + "\n") + "\n".join(
+            ["* " + suggestion for suggestion in self.suggested_action]
+        )
+
+
+class ArchiveDownloadError(AqtException):
+    pass
+
+
+class ArchiveConnectionError(AqtException):
+    pass
+
+
+class ArchiveListError(AqtException):
+    pass
+
+
+class NoPackageFound(AqtException):
+    pass
+
+
+class EmptyMetadata(AqtException):
     pass
 
 
-class ArchiveConnectionError(Exception):
+class CliInputError(AqtException):
     pass
 
 
-class ArchiveListError(Exception):
+class CliKeyboardInterrupt(AqtException):
     pass
 
 
-class NoPackageFound(Exception):
+class ArchiveExtractionError(AqtException):
     pass
 
 
-class CliInputError(Exception):
+class UpdaterError(AqtException):
     pass
diff --git a/aqt/helper.py b/aqt/helper.py
index 09bfe1b..b723552 100644
--- a/aqt/helper.py
+++ b/aqt/helper.py
@@ -66,15 +66,12 @@ def getUrl(url: str, timeout) -> str:
             ConnectionResetError,
             requests.exceptions.ConnectionError,
             requests.exceptions.Timeout,
-        ):
-            raise ArchiveConnectionError("Failure to connect to  {}".format(url))
+        ) as e:
+            raise ArchiveConnectionError(f"Failure to connect to {url}: {type(e).__name__}") from e
         else:
             if r.status_code != 200:
-                logger.error(
-                    "Failed to retrieve file at {}\n"
-                    "Server response code: {}, reason: {}".format(url, r.status_code, r.reason)
-                )
-                raise ArchiveDownloadError("Failure to retrieve {}".format(url))
+                msg = f"Failed to retrieve file at {url}\nServer response code: {r.status_code}, reason: {r.reason}"
+                raise ArchiveDownloadError(msg)
         result = r.text
     return result
 
@@ -94,11 +91,9 @@ def downloadBinaryFile(url: str, out: str, hash_algo: str, exp: str, timeout):
                 logger.info("Redirected: {}".format(urlparse(newurl).hostname))
                 r = session.get(newurl, stream=True, timeout=timeout)
         except requests.exceptions.ConnectionError as e:
-            logger.error("Connection error: %s" % e.args)
-            raise e
+            raise ArchiveConnectionError(f"Connection error: {e.args}") from e
         except requests.exceptions.Timeout as e:
-            logger.error("Connection timeout: %s" % e.args)
-            raise e
+            raise ArchiveConnectionError(f"Connection timeout: {e.args}") from e
         else:
             hash = hashlib.new(hash_algo)
             try:
@@ -107,17 +102,12 @@ def downloadBinaryFile(url: str, out: str, hash_algo: str, exp: str, timeout):
                         fd.write(chunk)
                         hash.update(chunk)
                     fd.flush()
-                if exp is not None:
-                    if hash.digest() != exp:
-                        raise ArchiveDownloadError(
-                            "Download file is corrupted! Detect checksum error.\nExpected {}, Actual {}".format(
-                                exp, hash.digest()
-                            )
-                        )
             except Exception as e:
-                exc = sys.exc_info()
-                logger.error("Download error: %s" % exc[1])
-                raise e
+                raise ArchiveDownloadError(f"Download error: {e}") from e
+            if exp is not None and hash.digest() != exp:
+                raise ArchiveDownloadError(
+                    f"Download file is corrupted! Detect checksum error.\nExpected {exp}, Actual {hash.digest()}"
+                )
 
 
 def altlink(url: str, alt: str):
@@ -349,6 +339,10 @@ class SettingsClass:
     def kde_patches(self):
         return self.config.getlist("kde_patches", "patches", fallback=[])
 
+    @property
+    def print_stacktrace_on_error(self):
+        return self.config.getboolean("aqt", "print_stacktrace_on_error", fallback=False)
+
 
 Settings = SettingsClass()
 
diff --git a/aqt/installer.py b/aqt/installer.py
index 5543591..f021086 100644
--- a/aqt/installer.py
+++ b/aqt/installer.py
@@ -29,14 +29,15 @@ import platform
 import random
 import signal
 import subprocess
+import sys
 import time
 from logging import getLogger
 from logging.handlers import QueueHandler
-from typing import List, Optional
+from typing import Any, Callable, List, Optional
 
 import aqt
 from aqt.archives import QtArchives, QtPackage, SrcDocExamplesArchives, ToolArchives
-from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError, ArchiveListError, NoPackageFound
+from aqt.exceptions import AqtException, ArchiveConnectionError, ArchiveExtractionError, CliInputError, CliKeyboardInterrupt
 from aqt.helper import MyQueueListener, Settings, downloadBinaryFile, getUrl, setup_logging
 from aqt.metadata import ArchiveId, MetadataFactory, SimpleSpec, Version, show_list
 from aqt.updater import Updater
@@ -83,11 +84,28 @@ class Cli:
         parser.set_defaults(func=self.show_help)
         self.parser = parser
 
-    def run(self, arg=None):
+    def run(self, arg=None) -> int:
         args = self.parser.parse_args(arg)
         self._setup_settings(args)
-        result = args.func(args)
-        return result
+        try:
+            args.func(args)
+            return 0
+        except AqtException as e:
+            self.logger.error(format(e), exc_info=Settings.print_stacktrace_on_error)
+            if e.should_show_help:
+                self.show_help()
+            return 1
+        except Exception as e:
+            # If we didn't account for it, and wrap it in an AqtException, it's a bug.
+            self.logger.exception(e)  # Print stack trace
+            self.logger.error(
+                f"Arguments: `{sys.argv}` Host: `{platform.uname()}`\n"
+                "===========================PLEASE FILE A BUG REPORT===========================\n"
+                "You have discovered a bug in aqt.\n"
+                "Please file a bug report at https://github.com/miurahr/aqtinstall/issues.\n"
+                "Please remember to include a copy of this program's output in your report."
+            )
+            return 1
 
     def _check_tools_arg_combination(self, os_name, tool_name, arch):
         for c in Settings.tools_combinations:
@@ -119,7 +137,7 @@ class Cli:
                 stderr=subprocess.DEVNULL,
             )
         except FileNotFoundError as e:
-            raise Exception("Specified 7zip command executable does not exist: {!r}".format(sevenzip)) from e
+            raise CliInputError("Specified 7zip command executable does not exist: {!r}".format(sevenzip)) from e
 
         return sevenzip
 
@@ -135,13 +153,9 @@ class Cli:
             elif target == "android" and Version(qt_version) >= Version("5.14.0"):
                 arch = "android"
             else:
-                print("Please supply a target architecture.")
-                self.show_help(args)
-                exit(1)
+                raise CliInputError("Please supply a target architecture.", should_show_help=True)
         if arch == "":
-            print("Please supply a target architecture.")
-            self.show_help(args)
-            exit(1)
+            raise CliInputError("Please supply a target architecture.", should_show_help=True)
         return arch
 
     def _check_mirror(self, mirror):
@@ -171,9 +185,7 @@ class Cli:
         target = args.target
         os_name = args.host
         qt_version = args.qt_version
-        if not Cli._is_valid_version_str(qt_version):
-            self.logger.error("Invalid version: '{}'! Please use the form '5.X.Y'.".format(qt_version))
-            exit(1)
+        Cli._validate_version_str(qt_version)
         keep = args.keep
         output_dir = args.outputdir
         if output_dir is None:
@@ -192,19 +204,19 @@ class Cli:
             sevenzip = self._set_sevenzip("7z")
         if args.base is not None:
             if not self._check_mirror(args.base):
-                self.show_help()
-                exit(1)
+                raise CliInputError(
+                    "The `--base` option requires a url where the path `online/qtsdkrepository` exists.",
+                    should_show_help=True,
+                )
             base = args.base
         else:
             base = Settings.baseurl
         archives = args.archives
         if args.noarchives:
             if modules is None:
-                print("When specified option --no-archives, an option --modules is mandatory.")
-                exit(1)
+                raise CliInputError("When `--noarchives` is set, the `--modules` option is mandatory.")
             if archives is not None:
-                print("Option --archives and --no-archives  are conflicted. Aborting...")
-                exit(1)
+                raise CliInputError("Options `--archives` and `--noarchives` are mutually exclusive.")
             else:
                 archives = modules
         else:
@@ -220,41 +232,23 @@ class Cli:
         all_extra = True if modules is not None and "all" in modules else False
         if not all_extra and not self._check_modules_arg(qt_version, modules):
             self.logger.warning("Some of specified modules are unknown.")
-        try:
-            qt_archives = QtArchives(
+
+        qt_archives = self.retry_on_bad_connection(
+            lambda base_url: QtArchives(
                 os_name,
                 target,
                 qt_version,
                 arch,
-                base,
+                base=base_url,
                 subarchives=archives,
                 modules=modules,
                 all_extra=all_extra,
                 timeout=timeout,
-            )
-        except ArchiveConnectionError:
-            try:
-                self.logger.warning("Connection to the download site failed and fallback to mirror site.")
-                qt_archives = QtArchives(
-                    os_name,
-                    target,
-                    qt_version,
-                    arch,
-                    random.choice(Settings.fallbacks),
-                    subarchives=archives,
-                    modules=modules,
-                    all_extra=all_extra,
-                    timeout=timeout,
-                )
-            except Exception:
-                self.logger.error("Connection to the download site failed. Aborted...")
-                exit(1)
-        except (ArchiveDownloadError, ArchiveListError, NoPackageFound):
-            exit(1)
+            ),
+            base,
+        )
         target_config = qt_archives.get_target_config()
-        result = run_installer(qt_archives.get_packages(), base_dir, sevenzip, keep)
-        if not result:
-            exit(1)
+        run_installer(qt_archives.get_packages(), base_dir, sevenzip, keep)
         if not nopatch:
             Updater.update(target_config, base_dir)
         self.logger.info("Finished installation")
@@ -270,9 +264,7 @@ class Cli:
         target = args.target
         os_name = args.host
         qt_version = args.qt_version
-        if not Cli._is_valid_version_str(qt_version):
-            self.logger.error("Invalid version: '{}'! Please use the form '5.X.Y'.".format(qt_version))
-            exit(1)
+        Cli._validate_version_str(qt_version)
         output_dir = args.outputdir
         if output_dir is None:
             base_dir = os.getcwd()
@@ -296,49 +288,28 @@ class Cli:
         all_extra = True if modules is not None and "all" in modules else False
         if not self._check_qt_arg_versions(qt_version):
             self.logger.warning("Specified Qt version is unknown: {}.".format(qt_version))
-        try:
-            srcdocexamples_archives = SrcDocExamplesArchives(
+
+        srcdocexamples_archives: SrcDocExamplesArchives = self.retry_on_bad_connection(
+            lambda base_url: SrcDocExamplesArchives(
                 flavor,
                 os_name,
                 target,
                 qt_version,
-                base,
+                base=base_url,
                 subarchives=archives,
                 modules=modules,
                 all_extra=all_extra,
                 timeout=timeout,
-            )
-        except ArchiveConnectionError:
-            try:
-                self.logger.warning("Connection to the download site failed and fallback to mirror site.")
-                srcdocexamples_archives = SrcDocExamplesArchives(
-                    flavor,
-                    os_name,
-                    target,
-                    qt_version,
-                    random.choice(Settings.fallbacks),
-                    subarchives=archives,
-                    modules=modules,
-                    all_extra=all_extra,
-                    timeout=timeout,
-                )
-            except Exception:
-                self.logger.error("Connection to the download site failed. Aborted...")
-                exit(1)
-        except (ArchiveDownloadError, ArchiveListError, NoPackageFound):
-            exit(1)
-        result = run_installer(srcdocexamples_archives.get_packages(), base_dir, sevenzip, keep)
-        if result:
-            self.logger.info("Finished installation")
-        else:
-            exit(1)
+            ),
+            base,
+        )
+        run_installer(srcdocexamples_archives.get_packages(), base_dir, sevenzip, keep)
+        self.logger.info("Finished installation")
 
     def run_install_src(self, args):
         """Run src subcommand"""
-        if args.kde:
-            if args.qt_version != "5.15.2":
-                print("KDE patch: unsupported version!!")
-                exit(1)
+        if args.kde and args.qt_version != "5.15.2":
+            raise CliInputError("KDE patch: unsupported version!!")
         start_time = time.perf_counter()
         self._run_src_doc_examples("src", args)
         if args.kde:
@@ -402,60 +373,40 @@ class Cli:
             if not self._check_tools_arg_combination(os_name, tool_name, arch):
                 self.logger.warning("Specified target combination is not valid: {} {} {}".format(os_name, tool_name, arch))
 
-            try:
-                tool_archives = ToolArchives(
+            tool_archives: ToolArchives = self.retry_on_bad_connection(
+                lambda base_url: ToolArchives(
                     os_name=os_name,
                     tool_name=tool_name,
                     target=target,
-                    base=base,
+                    base=base_url,
                     version_str=version,
                     arch=arch,
                     timeout=timeout,
-                )
-            except ArchiveConnectionError:
-                try:
-                    self.logger.warning("Connection to the download site failed and fallback to mirror site.")
-                    tool_archives = ToolArchives(
-                        os_name=os_name,
-                        target=target,
-                        tool_name=tool_name,
-                        base=random.choice(Settings.fallbacks),
-                        version_str=version,
-                        arch=arch,
-                        timeout=timeout,
-                    )
-                except Exception:
-                    self.logger.error("Connection to the download site failed. Aborted...")
-                    exit(1)
-            except (ArchiveDownloadError, ArchiveListError, NoPackageFound):
-                exit(1)
-            if not run_installer(tool_archives.get_packages(), base_dir, sevenzip, keep):
-                exit(1)
+                ),
+                base,
+            )
+            run_installer(tool_archives.get_packages(), base_dir, sevenzip, keep)
         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) -> int:
+    def run_list_qt(self, args: argparse.ArgumentParser):
         """Print versions of Qt, extensions, modules, architectures"""
 
         if not args.target:
             print(" ".join(ArchiveId.TARGETS_FOR_HOST[args.host]))
-            return 0
+            return
         if args.target not in ArchiveId.TARGETS_FOR_HOST[args.host]:
-            self.logger.error("'{0.target}' is not a valid target for host '{0.host}'".format(args))
-            return 1
+            raise CliInputError("'{0.target}' is not a valid target for host '{0.host}'".format(args))
 
         for version_str in (args.modules, args.extensions, args.arch):
-            if not Cli._is_valid_version_str(version_str, allow_latest=True, allow_empty=True):
-                self.logger.error("Invalid version: '{}'! Please use the form '5.X.Y'.".format(version_str))
-                exit(1)  # TODO: maybe return 1 instead?
+            Cli._validate_version_str(version_str, allow_latest=True, allow_empty=True)
 
         spec = None
         try:
             if args.spec is not None:
                 spec = SimpleSpec(args.spec)
-        except ValueError:
-            self.logger.error(f"Invalid version specification: '{args.spec}'.\n" + SimpleSpec.usage())
-            return 1
+        except ValueError as e:
+            raise CliInputError(f"Invalid version specification: '{args.spec}'.\n" + SimpleSpec.usage()) from e
 
         meta = MetadataFactory(
             archive_id=ArchiveId(
@@ -470,24 +421,23 @@ class Cli:
             extensions_ver=args.extensions,
             architectures_ver=args.arch,
         )
-        return show_list(meta)
+        show_list(meta)
 
-    def run_list_tool(self, args: argparse.ArgumentParser) -> int:
+    def run_list_tool(self, args: argparse.ArgumentParser):
         """Print tools"""
 
         if not args.target:
             print(" ".join(ArchiveId.TARGETS_FOR_HOST[args.host]))
-            return 0
+            return
         if args.target not in ArchiveId.TARGETS_FOR_HOST[args.host]:
-            self.logger.error("'{0.target}' is not a valid target for host '{0.host}'".format(args))
-            return 1
+            raise CliInputError("'{0.target}' is not a valid target for host '{0.host}'".format(args))
 
         meta = MetadataFactory(
             archive_id=ArchiveId("tools", args.host, args.target),
             tool_name=args.tool_name,
             is_long_listing=args.long,
         )
-        return show_list(meta)
+        show_list(meta)
 
     def show_help(self, args=None):
         """Display help message"""
@@ -780,18 +730,25 @@ class Cli:
                 Settings.load_settings()
 
     @staticmethod
-    def _is_valid_version_str(version_str: str, *, allow_latest: bool = False, allow_empty: bool = False) -> bool:
+    def _validate_version_str(version_str: str, *, allow_latest: bool = False, allow_empty: bool = False):
         if (allow_latest and version_str == "latest") or (allow_empty and not version_str):
-            return True
+            return
         try:
             Version(version_str)
-            return True
-        except ValueError:
-            return False
+        except ValueError as e:
+            raise CliInputError(f"Invalid version: '{version_str}'! Please use the form '5.X.Y'.") from e
+
+    def retry_on_bad_connection(self, function: Callable[[str], Any], base_url: str):
+        fallback_url = random.choice(Settings.fallbacks)
+        try:
+            return function(base_url)
+        except ArchiveConnectionError:
+            self.logger.warning(f"Connection to '{base_url}' failed. Retrying with fallback '{fallback_url}'.")
+            return function(fallback_url)
 
 
-def run_installer(archives: List[QtPackage], base_dir: str, sevenzip: Optional[str], keep: bool) -> bool:
-    result = True
+def run_installer(archives: List[QtPackage], base_dir: str, sevenzip: Optional[str], keep: bool):
+    key_interrupt = False
     queue = multiprocessing.Manager().Queue(-1)
     listener = MyQueueListener(queue)
     listener.start()
@@ -810,11 +767,12 @@ def run_installer(archives: List[QtPackage], base_dir: str, sevenzip: Optional[s
         logger.warning("Caught KeyboardInterrupt, terminating installer workers")
         pool.terminate()
         pool.join()
-        result = False
+        key_interrupt = True
     # all done, close logging service for sub-processes
     listener.enqueue_sentinel()
     listener.stop()
-    return result
+    if key_interrupt:
+        raise CliKeyboardInterrupt("Installer halted by keyboard interrupt.")
 
 
 def init_worker_sh():
@@ -877,12 +835,8 @@ def installer(
             proc = subprocess.run(command_args, stdout=subprocess.PIPE, check=True)
             logger.debug(proc.stdout)
         except subprocess.CalledProcessError as cpe:
-            logger.error("Extraction error: %d" % cpe.returncode)
-            if cpe.stdout is not None:
-                logger.error(cpe.stdout)
-            if cpe.stderr is not None:
-                logger.error(cpe.stderr)
-            raise cpe
+            msg = "\n".join(filter(None, [f"Extraction error: {cpe.returncode}", cpe.stdout, cpe.stderr]))
+            raise ArchiveExtractionError(msg) from cpe
     if not keep:
         os.unlink(archive)
     logger.info("Finished installation of {} in {:.8f}".format(archive, time.perf_counter() - start_time))
diff --git a/aqt/metadata.py b/aqt/metadata.py
index 1dfb87a..e240ddc 100644
--- a/aqt/metadata.py
+++ b/aqt/metadata.py
@@ -34,7 +34,7 @@ from semantic_version import SimpleSpec as SemanticSimpleSpec
 from semantic_version import Version as SemanticVersion
 from texttable import Texttable
 
-from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError, CliInputError
+from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError, CliInputError, EmptyMetadata
 from aqt.helper import Settings, getUrl, xml_to_modules
 
 
@@ -314,6 +314,9 @@ class ToolData:
             table.add_rows(self._rows(), header=False)
         return table.draw()
 
+    def __bool__(self):
+        return bool(self.tool_data)
+
     def _rows(self):
         keys = ("Version", "ReleaseDate", "DisplayName", "Description")
         return [[name, *[content[key] for key in keys]] for name, content in self.tool_data.items()]
@@ -456,7 +459,7 @@ class MetadataFactory:
         1. On Qt6 for Android, an extension for processor architecture is required.
         2. On any platform other than Android, or on Qt5, an extension for
         processor architecture is forbidden.
-        3. The "wasm" extension only works on desktop targets for Qt 5.13-5.15, or for 6.2+
+        3. The "wasm" extension only works on desktop targets for Qt 5.13-5.15.
         """
         if (
             self.archive_id.target == "android"
@@ -472,10 +475,11 @@ class MetadataFactory:
             self.archive_id.target != "android" or qt_ver.major != 6
         ):
             raise CliInputError(f"The extension '{self.archive_id.extension}' is only valid for Qt 6 for Android")
-        is_in_wasm_range = qt_ver in SimpleSpec(">=5.13,<6") or qt_ver in SimpleSpec(">=6.2.0")
-        if "wasm" in self.archive_id.extension and (self.archive_id.target != "desktop" or not is_in_wasm_range):
+        if "wasm" in self.archive_id.extension and (
+            qt_ver not in SimpleSpec(">=5.13,<6") or self.archive_id.target != "desktop"
+        ):
             raise CliInputError(
-                f"The extension '{self.archive_id.extension}' is only available in Qt 5.13-5.15 and 6.2+ on desktop."
+                f"The extension '{self.archive_id.extension}' is only available in Qt 5.13 to 5.15 on desktop."
             )
 
     @staticmethod
@@ -522,7 +526,7 @@ class MetadataFactory:
         try:
             version = Version(qt_ver)
         except ValueError as e:
-            raise CliInputError(e)
+            raise CliInputError(e) from e
         return version
 
     @staticmethod
@@ -538,7 +542,7 @@ class MetadataFactory:
 
             except (ArchiveDownloadError, ArchiveConnectionError) as e:
                 if i == len(base_urls) - 1:
-                    raise e
+                    raise e from e
 
     @staticmethod
     def iterate_folders(html_doc: str, filter_category: str = "") -> Generator[str, None, None]:
@@ -661,24 +665,13 @@ def suggested_follow_up(meta: MetadataFactory) -> List[str]:
     return msg
 
 
-def format_suggested_follow_up(suggestions: Iterable[str]) -> str:
-    if not suggestions:
-        return ""
-    return ("=" * 30 + "Suggested follow-up:" + "=" * 30 + "\n") + "\n".join(
-        ["* " + suggestion for suggestion in suggestions]
-    )
-
-
-def show_list(meta: MetadataFactory) -> int:
-    logger = getLogger("aqt.metadata")
+def show_list(meta: MetadataFactory):
     try:
         output = meta.getList()
         if not output:
-            logger.info("No {} available for this request.".format(meta.request_type))
-            suggestions = suggested_follow_up(meta)
-            if suggestions:
-                logger.info(format_suggested_follow_up(suggestions))
-            return 1
+            raise EmptyMetadata(
+                f"No {meta.request_type} available for this request.", suggested_action=suggested_follow_up(meta)
+            )
         if isinstance(output, Versions):
             print(format(output))
         elif isinstance(output, ToolData):
@@ -693,13 +686,6 @@ def show_list(meta: MetadataFactory) -> int:
             print(*output, sep="\n")
         else:
             print(*output, sep=" ")
-        return 0
-    except CliInputError as e:
-        logger.error("Command line input error: {}".format(e))
-        return 1
-    except (ArchiveConnectionError, ArchiveDownloadError) as e:
-        logger.error("{}".format(e))
-        suggestions = suggested_follow_up(meta)
-        if suggestions:
-            logger.error(format_suggested_follow_up(suggestions))
-        return 1
+    except (ArchiveDownloadError, ArchiveConnectionError) as e:
+        e.suggested_action = suggested_follow_up(meta)
+        raise e from e
diff --git a/aqt/settings.ini b/aqt/settings.ini
index a7c72fd..b8cd797 100644
--- a/aqt/settings.ini
+++ b/aqt/settings.ini
@@ -4,6 +4,7 @@
 concurrency: 4
 baseurl: https://download.qt.io
 7zcmd: 7z
+print_stacktrace_on_error: False
 
 [requests]
 connection_timeout: 3.5
diff --git a/aqt/updater.py b/aqt/updater.py
index ca75402..dc08f6e 100644
--- a/aqt/updater.py
+++ b/aqt/updater.py
@@ -27,6 +27,8 @@ from logging import getLogger
 
 import patch
 
+from aqt.archives import TargetConfig
+from aqt.exceptions import UpdaterError
 from aqt.helper import Settings
 from aqt.metadata import SimpleSpec, Version
 
@@ -249,7 +251,7 @@ class Updater:
         self._patch_textfile(target_qt_conf, "HostData=target", new_hostdata)
 
     @classmethod
-    def update(cls, target, base_dir: str):
+    def update(cls, target: TargetConfig, base_dir: str):
         """
         Make Qt configuration files, qt.conf and qtconfig.pri.
         And update pkgconfig and patch Qt5Core and qmake
@@ -307,7 +309,7 @@ class Updater:
                 updater.patch_qmake_script(base_dir, version_dir, target.os_name)
                 updater.patch_target_qt_conf(base_dir, version_dir, arch_dir, target.os_name)
         except IOError as e:
-            raise e
+            raise UpdaterError(f"Updater caused an IO error: {e}") from e
 
     @classmethod
     def patch_kde(cls, src_dir):
diff --git a/docs/conf.py b/docs/conf.py
index 041b31c..27ede61 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -21,8 +21,6 @@ import os
 sys.path.insert(0, os.path.abspath('.'))
 sys.path.insert(0, os.path.abspath('..'))
 
-import aqt.version
-
 # -- General configuration ------------------------------------------------
 
 # If your documentation needs a minimal Sphinx version, state it here.
@@ -62,10 +60,10 @@ author = u'Hiroshi Miura'
 # |version| and |release|, also used in various other places throughout the
 # built documents.
 #
-# The full version, including alpha/beta/rc tags.
-release = aqt.version.__version__
 # The short X.Y version.
-version = '.'.join(release.split('.')[:2])
+version = u'1.0'
+# The full version, including alpha/beta/rc tags.
+release = u'1.0.0b2'
 
 # The language for content autogenerated by Sphinx. Refer to documentation
 # for a list of supported languages.
diff --git a/pyproject.toml b/pyproject.toml
index e564bbe..27c5c65 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
 local_scheme = "no-local-version"
 
 [tool.check-manifest]
-ignore = ["aqt/version.py","azure-pipelines.yml", ".readthedocs.yml", "ci/*"]
+ignore = ["aqt/version.py","azure-pipelines.yml","ci/*"]
 
 [tool.coverage.run]
 branch = true
diff --git a/setup.cfg b/setup.cfg
index fa9d067..22815fe 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -15,12 +15,6 @@ license = MIT
 author = Hiroshi Miura
 author_email = miurahr@linux.com
 url = http://github.com/miurahr/aqtinstall
-project_urls =
-    Documentation = https://aqtinstall.readthedocs.io/
-    Bug Tracker = https://github.com/miurahr/aqtinstall/issues
-    Wiki = https://github.com/miurahr/aqtinstall/wiki
-    Source = https://github.com/miurahr/aqtinstall
-    Changelog = https://github.com/miurahr/aqtinstall/blob/master/CHANGELOG.rst
 classifiers =
     Development Status :: 4 - Beta
     Environment :: Console


Test patch:
diff --git a/tests/test_archives.py b/tests/test_archives.py
index 7077c29..f4fe90c 100644
--- a/tests/test_archives.py
+++ b/tests/test_archives.py
@@ -9,7 +9,7 @@ from typing import Dict, Iterable
 import pytest
 
 from aqt.archives import ModuleToPackage, QtArchives, QtPackage, ToolArchives
-from aqt.exceptions import NoPackageFound
+from aqt.exceptions import ArchiveListError, NoPackageFound
 from aqt.helper import Settings
 from aqt.metadata import Version
 
@@ -61,6 +61,27 @@ def test_parse_update_xml(monkeypatch, os_name, version, arch, datafile):
     assert unwanted_targets == []
 
 
+@pytest.fixture()
+def corrupt_xmlfile():
+    return "<UnclosedTag></SomeOtherTag>"
+
+
+@pytest.mark.parametrize(
+    "archives_class, init_args",
+    (
+        (QtArchives, ("mac", "desktop", "1.2.3", "clang", Settings.baseurl)),
+        (ToolArchives, ("mac", "desktop", "tools_qtifw", Settings.baseurl)),
+    ),
+)
+def test_qtarchive_parse_corrupt_xmlfile(monkeypatch, corrupt_xmlfile, archives_class, init_args):
+    monkeypatch.setattr("aqt.archives.getUrl", lambda self, url: corrupt_xmlfile)
+
+    with pytest.raises(ArchiveListError) as error:
+        archives_class(*init_args)
+    assert error.type == ArchiveListError
+    assert format(error.value) == "Downloaded metadata is corrupted. mismatched tag: line 1, column 15"
+
+
 @pytest.mark.parametrize(
     "arch, requested_module_names, has_nonexistent_modules",
     (
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 2aac556..c0c4568 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -3,42 +3,45 @@ import sys
 
 import pytest
 
+from aqt.exceptions import CliInputError
 from aqt.installer import Cli
 from aqt.metadata import Version
 
 
-def test_cli_help(capsys):
-    expected = "".join(
-        [
-            "usage: aqt [-h] [-c CONFIG]\n",
-            "           {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,",
-            "install,tool,doc,example,src,help,version}\n",
-            "           ...\n",
-            "\n",
-            "Another unofficial Qt Installer.\n",
-            "aqt helps you install Qt SDK, tools, examples and others\n",
-            "\n",
-            "optional arguments:\n",
-            "  -h, --help            show this help message and exit\n",
-            "  -c CONFIG, --config CONFIG\n",
-            "                        Configuration ini file.\n",
-            "\n",
-            "subcommands:\n",
-            "  aqt accepts several subcommands:\n",
-            "  install-* subcommands are commands that install components\n",
-            "  list-* subcommands are commands that show available components\n",
-            "  \n",
-            "  commands {install|tool|src|examples|doc} are deprecated and marked for removal\n",
-            "\n",
-            "  {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,",
-            "install,tool,doc,example,src,help,version}\n",
-            "                        Please refer to each help message by using '--help' with each subcommand\n",
-        ]
+@pytest.fixture()
+def expected_help():
+    return (
+        "usage: aqt [-h] [-c CONFIG]\n"
+        "           {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,"
+        "install,tool,doc,example,src,help,version}\n"
+        "           ...\n"
+        "\n"
+        "Another unofficial Qt Installer.\n"
+        "aqt helps you install Qt SDK, tools, examples and others\n"
+        "\n"
+        "optional arguments:\n"
+        "  -h, --help            show this help message and exit\n"
+        "  -c CONFIG, --config CONFIG\n"
+        "                        Configuration ini file.\n"
+        "\n"
+        "subcommands:\n"
+        "  aqt accepts several subcommands:\n"
+        "  install-* subcommands are commands that install components\n"
+        "  list-* subcommands are commands that show available components\n"
+        "  \n"
+        "  commands {install|tool|src|examples|doc} are deprecated and marked for removal\n"
+        "\n"
+        "  {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,"
+        "install,tool,doc,example,src,help,version}\n"
+        "                        Please refer to each help message by using '--help' with each subcommand\n"
     )
+
+
+def test_cli_help(capsys, expected_help):
     cli = Cli()
     cli.run(["help"])
     out, err = capsys.readouterr()
-    assert out == expected
+    assert out == expected_help
 
 
 def test_cli_check_module():
@@ -89,11 +92,8 @@ def test_cli_invalid_version(capsys, invalid_version):
         ("install-doc", "mac", "desktop", invalid_version),
         ("list-qt", "mac", "desktop", "--modules", invalid_version),
     ):
-        with pytest.raises(SystemExit) as pytest_wrapped_e:
-            cli = Cli()
-            cli.run(cmd)
-        assert pytest_wrapped_e.type == SystemExit
-        assert pytest_wrapped_e.value.code == 1
+        cli = Cli()
+        assert cli.run(cmd) == 1
         out, err = capsys.readouterr()
         sys.stdout.write(out)
         sys.stderr.write(err)
@@ -108,3 +108,96 @@ def test_cli_check_mirror():
     args = cli.parser.parse_args(arg)
     assert args.base == "https://download.qt.io/"
     assert cli._check_mirror(args.base)
+
+
+@pytest.mark.parametrize(
+    "arch, host, target, version, expect",
+    (
+        (None, "windows", "desktop", "6.2.0", None),
+        ("", "windows", "desktop", "6.2.0", None),
+        (None, "linux", "desktop", "6.2.0", "gcc_64"),
+        (None, "mac", "desktop", "6.2.0", "clang_64"),
+        (None, "mac", "ios", "6.2.0", "ios"),
+        (None, "mac", "android", "6.2.0", "android"),
+        (None, "mac", "android", "5.12.0", None),
+    ),
+)
+def test_set_arch(arch, host, target, version, expect):
+    cli = Cli()
+    cli._setup_settings()
+
+    if not expect:
+        with pytest.raises(CliInputError) as e:
+            cli._set_arch([], arch, host, target, version)
+        assert e.type == CliInputError
+        assert format(e.value) == "Please supply a target architecture."
+        assert e.value.should_show_help is True
+    else:
+        assert cli._set_arch([], arch, host, target, version) == expect
+
+
+@pytest.mark.parametrize(
+    "cmd, expect_msg, should_show_help",
+    (
+        (
+            "install-qt mac ios 6.2.0 --base not_a_url",
+            "The `--base` option requires a url where the path `online/qtsdkrepository` exists.",
+            True,
+        ),
+        (
+            "install-qt mac ios 6.2.0 --noarchives",
+            "When `--noarchives` is set, the `--modules` option is mandatory.",
+            False,
+        ),
+        (
+            "install-qt mac ios 6.2.0 --noarchives --archives",
+            "When `--noarchives` is set, the `--modules` option is mandatory.",
+            False,
+        ),
+        (
+            "install-qt mac ios 6.2.0 --noarchives --archives --modules qtcharts",
+            "Options `--archives` and `--noarchives` are mutually exclusive.",
+            False,
+        ),
+        (
+            "install-src mac ios 6.2.0 --kde",
+            "KDE patch: unsupported version!!",
+            False,
+        ),
+    ),
+)
+def test_cli_input_errors(capsys, expected_help, cmd, expect_msg, should_show_help):
+    cli = Cli()
+    cli._setup_settings()
+    assert 1 == cli.run(cmd.split())
+    out, err = capsys.readouterr()
+    assert out == (expected_help if should_show_help else "")
+    assert err.rstrip().endswith(expect_msg)
+
+
+def test_cli_unexpected_error(monkeypatch, capsys):
+    def _mocked_run(*args):
+        raise RuntimeError("Some unexpected error")
+
+    monkeypatch.setattr("aqt.installer.Cli.run_install_qt", _mocked_run)
+
+    cli = Cli()
+    cli._setup_settings()
+    assert 1 == cli.run(["install-qt", "mac", "ios", "6.2.0"])
+    out, err = capsys.readouterr()
+    assert err.startswith("Some unexpected error")
+    assert err.rstrip().endswith(
+        "===========================PLEASE FILE A BUG REPORT===========================\n"
+        "You have discovered a bug in aqt.\n"
+        "Please file a bug report at https://github.com/miurahr/aqtinstall/issues.\n"
+        "Please remember to include a copy of this program's output in your report."
+    )
+
+
+def test_cli_set_7zip(monkeypatch):
+    cli = Cli()
+    cli._setup_settings()
+    with pytest.raises(CliInputError) as err:
+        cli._set_sevenzip("some_nonexistent_binary")
+    assert err.type == CliInputError
+    assert format(err.value) == "Specified 7zip command executable does not exist: 'some_nonexistent_binary'"
diff --git a/tests/test_connection.py b/tests/test_connection.py
index 8b0e96a..d6f6fbf 100644
--- a/tests/test_connection.py
+++ b/tests/test_connection.py
@@ -10,11 +10,8 @@ import aqt
 def test_cli_unknown_version(capsys):
     wrong_version = "5.16.0"
     wrong_url_ending = "mac_x64/desktop/qt5_5160/Updates.xml"
-    with pytest.raises(SystemExit) as pytest_wrapped_e:
-        cli = aqt.installer.Cli()
-        cli.run(["install-qt", "mac", "desktop", wrong_version])
-    assert pytest_wrapped_e.type == SystemExit
-    assert pytest_wrapped_e.value.code == 1
+    cli = aqt.installer.Cli()
+    assert cli.run(["install-qt", "mac", "desktop", wrong_version]) == 1
     out, err = capsys.readouterr()
     sys.stdout.write(out)
     sys.stderr.write(err)
diff --git a/tests/test_helper.py b/tests/test_helper.py
index 66632a1..b1b1a1c 100644
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -1,14 +1,26 @@
 import binascii
 import os
+import re
+from typing import Dict
+from urllib.parse import urlparse
 
 import pytest
 import requests
+from pytest_socket import disable_socket
 from requests.models import Response
 
 from aqt import helper
+from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError
+from aqt.helper import getUrl
 from aqt.metadata import Version
 
 
+@pytest.fixture(autouse=True)
+def disable_sockets():
+    # This blocks all network connections, causing test failure if we used monkeypatch wrong
+    disable_socket()
+
+
 def test_helper_altlink(monkeypatch):
     class Message:
         headers = {"content-type": "text/plain", "length": 300}
@@ -91,6 +103,60 @@ def test_helper_downloadBinary_sha256(tmp_path, monkeypatch):
     helper.downloadBinaryFile("http://example.com/test.xml", out, "sha256", expected, 60)
 
 
+@pytest.mark.parametrize(
+    "mock_exception, expected_err_msg",
+    (
+        (requests.exceptions.ConnectionError("Connection failed!"), "Connection error: ('Connection failed!',)"),
+        (requests.exceptions.Timeout("Connection timed out!"), "Connection timeout: ('Connection timed out!',)"),
+    ),
+)
+def test_helper_downloadBinary_connection_err(tmp_path, monkeypatch, mock_exception, expected_err_msg):
+    def _mock_get_conn_error(*args, **kwargs):
+        raise mock_exception
+
+    monkeypatch.setattr(requests.Session, "get", _mock_get_conn_error)
+
+    expected = binascii.unhexlify("1d41a93e4a585bb01e4518d4af431933")
+    out = tmp_path.joinpath("text.xml")
+    with pytest.raises(ArchiveConnectionError) as e:
+        helper.downloadBinaryFile("http://example.com/test.xml", out, "md5", expected, 60)
+    assert e.type == ArchiveConnectionError
+    assert format(e.value) == expected_err_msg
+
+
+def test_helper_downloadBinary_wrong_checksum(tmp_path, monkeypatch):
+    monkeypatch.setattr(requests.Session, "get", mocked_requests_get)
+
+    actual_hash = binascii.unhexlify("1d41a93e4a585bb01e4518d4af431933")
+    wrong_hash = binascii.unhexlify("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
+    expected_err = f"Download file is corrupted! Detect checksum error.\nExpected {wrong_hash}, Actual {actual_hash}"
+    out = tmp_path.joinpath("text.xml")
+    with pytest.raises(ArchiveDownloadError) as e:
+        helper.downloadBinaryFile("http://example.com/test.xml", out, "md5", wrong_hash, 60)
+    assert e.type == ArchiveDownloadError
+    assert format(e.value) == expected_err
+
+
+def test_helper_downloadBinary_response_error_undefined(tmp_path, monkeypatch):
+    def iter_broken_content(*args, **kwargs):
+        raise RuntimeError("This chunk of downloaded content contains an error.")
+
+    def mock_requests_get(*args, **kwargs):
+        response = Response()
+        response.status_code = 200
+        response.iter_content = iter_broken_content
+        return response
+
+    monkeypatch.setattr(requests.Session, "get", mock_requests_get)
+
+    expected = binascii.unhexlify("1d41a93e4a585bb01e4518d4af431933")
+    out = tmp_path.joinpath("text.xml")
+    with pytest.raises(ArchiveDownloadError) as e:
+        helper.downloadBinaryFile("http://example.com/test.xml", out, "md5", expected, 60)
+    assert e.type == ArchiveDownloadError
+    assert format(e.value) == "Download error: This chunk of downloaded content contains an error."
+
+
 @pytest.mark.parametrize(
     "version, expect",
     [
@@ -108,3 +174,90 @@ def test_helper_downloadBinary_sha256(tmp_path, monkeypatch):
 )
 def test_helper_to_version_permissive(version, expect):
     assert Version.permissive(version) == expect
+
+
+def mocked_request_response_class(num_redirects: int = 0, forbidden_baseurls=None):
+    if not forbidden_baseurls:
+        forbidden_hostnames = []
+    else:
+        forbidden_hostnames = [urlparse(host).hostname for host in forbidden_baseurls]
+
+    class MockResponse:
+        redirects_for_host = {}
+
+        def __init__(self, url: str, headers: Dict, text: str):
+            self.url = url
+            self.headers = {key: value for key, value in headers.items()}
+
+            hostname = urlparse(url).hostname
+            if hostname not in MockResponse.redirects_for_host:
+                MockResponse.redirects_for_host[hostname] = num_redirects
+
+            if MockResponse.redirects_for_host[hostname] > 0:
+                MockResponse.redirects_for_host[hostname] -= 1
+                self.status_code = 302
+                self.headers["Location"] = f"{url}/redirect{MockResponse.redirects_for_host[hostname]}"
+                self.text = f"Still {MockResponse.redirects_for_host[hostname]} redirects to go..."
+                self.reason = "Redirect"
+            elif hostname in forbidden_hostnames:
+                raise requests.exceptions.ConnectionError()
+            else:
+                self.status_code = 200
+                self.text = text
+
+    return MockResponse
+
+
+def test_helper_getUrl_ok(monkeypatch):
+    response_class = mocked_request_response_class()
+
+    def _mock_get(url, **kwargs):
+        return response_class(url, {}, "some_html_content")
+
+    monkeypatch.setattr(requests, "get", _mock_get)
+    assert getUrl("some_url", timeout=(5, 5)) == "some_html_content"
+
+
+def mock_get_redirect(num_redirects: int):
+    response_class = mocked_request_response_class(num_redirects)
+
+    def _mock(url: str, timeout, allow_redirects):
+        return response_class(url, {}, text="some_html_content")
+
+    def _mock_session(self, url: str, timeout, stream):
+        return response_class(url, {}, text="some_html_content")
+
+    return _mock, _mock_session
+
+
+def test_helper_getUrl_redirect_5(monkeypatch):
+    mocked_get, mocked_session_get = mock_get_redirect(num_redirects=5)
+    monkeypatch.setattr(requests, "get", mocked_get)
+    monkeypatch.setattr(requests.Session, "get", mocked_session_get)
+    assert getUrl("some_url", (5, 5)) == "some_html_content"
+
+
+def test_helper_getUrl_redirect_too_many(monkeypatch):
+    mocked_get, mocked_session_get = mock_get_redirect(num_redirects=11)
+    monkeypatch.setattr(requests, "get", mocked_get)
+    monkeypatch.setattr(requests.Session, "get", mocked_session_get)
+    with pytest.raises(ArchiveDownloadError) as e:
+        getUrl("some_url", (5, 5))
+    assert e.type == ArchiveDownloadError
+
+
+def test_helper_getUrl_conn_error(monkeypatch):
+    response_class = mocked_request_response_class(forbidden_baseurls=["https://www.forbidden.com"])
+    url = "https://www.forbidden.com/some_path"
+    timeout = (5, 5)
+
+    expect_re = re.compile(r"^Failure to connect to.+" + re.escape(url))
+
+    def _mock(url: str, *args, **kwargs):
+        return response_class(url, {}, text="some_html_content")
+
+    monkeypatch.setattr(requests, "get", _mock)
+    with pytest.raises(ArchiveConnectionError) as e:
+        getUrl(url, timeout)
+    assert e.type == ArchiveConnectionError
+    assert expect_re.match(format(e.value))
diff --git a/tests/test_install.py b/tests/test_install.py
index 4b09ab3..f38455c 100644
--- a/tests/test_install.py
+++ b/tests/test_install.py
@@ -1,6 +1,7 @@
 import logging
 import os
 import re
+import subprocess
 import sys
 import textwrap
 from dataclasses import dataclass
@@ -13,7 +14,9 @@ import py7zr
 import pytest
 from pytest_socket import disable_socket
 
-from aqt.installer import Cli
+from aqt.archives import QtPackage
+from aqt.exceptions import ArchiveExtractionError
+from aqt.installer import Cli, installer
 
 
 class MockMultiprocessingContext:
@@ -45,7 +48,7 @@ class MockMultiprocessingContext:
             pass
 
         def terminate(self):
-            assert False, "Did not expect to call terminate during unit test"
+            pass
 
 
 class MockMultiprocessingManager:
@@ -497,15 +500,62 @@ def test_install_bad_tools_modules(monkeypatch, capsys, cmd, xml_file, expected_
     monkeypatch.setattr("aqt.archives.getUrl", mock_get_url)
     monkeypatch.setattr("aqt.installer.getUrl", mock_get_url)
 
-    with pytest.raises(SystemExit) as pytest_wrapped_e:
-        cli = Cli()
-        cli._setup_settings()
-        cli.run(cmd.split())
-    assert pytest_wrapped_e.type == SystemExit
-    assert pytest_wrapped_e.value.code == 1
+    cli = Cli()
+    cli._setup_settings()
+    assert cli.run(cmd.split()) == 1
 
     out, err = capsys.readouterr()
     match = expected_re.match(err)
     assert match
     actual = set(map(lambda x: x[1:-1], match.group(1).split(", ")))
     assert actual == expect_missing
+
+
+def test_install_keyboard_interrupt(monkeypatch, capsys):
+    def mock_keyboard_interrupt(*args):
+        raise KeyboardInterrupt()
+
+    host, target, ver, arch = "windows", "desktop", "6.1.0", "win64_mingw81"
+    updates_url = "windows_x86/desktop/qt6_610/Updates.xml"
+    archives = [plain_qtbase_archive("qt.qt6.610.win64_mingw81", "win64_mingw81")]
+
+    cmd = ["install-qt", host, target, ver, arch]
+    mock_get_url, mock_download_archive = make_mock_geturl_download_archive(archives, arch, host, updates_url)
+    monkeypatch.setattr("aqt.archives.getUrl", mock_get_url)
+    monkeypatch.setattr("aqt.installer.getUrl", mock_get_url)
+    monkeypatch.setattr("aqt.installer.installer", mock_keyboard_interrupt)
+
+    cli = Cli()
+    cli._setup_settings()
+    assert cli.run(cmd) == 1
+    out, err = capsys.readouterr()
+    assert err.rstrip().endswith(
+        "Caught KeyboardInterrupt, terminating installer workers\nInstaller halted by keyboard interrupt."
+    )
+
+
+def test_install_installer_archive_extraction_err(monkeypatch):
+    def mock_extractor_that_fails(*args, **kwargs):
+        raise subprocess.CalledProcessError(returncode=1, cmd="some command", output="out", stderr="err")
+
+    monkeypatch.setattr("aqt.installer.getUrl", lambda *args: "")
+    monkeypatch.setattr("aqt.installer.downloadBinaryFile", lambda *args: None)
+    monkeypatch.setattr("aqt.installer.subprocess.run", mock_extractor_that_fails)
+
+    with pytest.raises(ArchiveExtractionError) as err, TemporaryDirectory() as temp_dir:
+        installer(
+            qt_archive=QtPackage(
+                "name",
+                "archive-url",
+                "archive",
+                "package_desc",
+                "hashurl",
+                "pkg_update_name",
+            ),
+            base_dir=temp_dir,
+            command="some_nonexistent_7z_extractor",
+            queue=MockMultiprocessingManager.Queue(),
+        )
+    assert err.type == ArchiveExtractionError
+    err_msg = format(err.value).rstrip()
+    assert err_msg == "Extraction error: 1\nout\nerr"
diff --git a/tests/test_list.py b/tests/test_list.py
index 22ab192..29b0d02 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -9,20 +9,10 @@ from urllib.parse import urlparse
 
 import pytest
 
-from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError, CliInputError
+from aqt.exceptions import AqtException, ArchiveConnectionError, ArchiveDownloadError, CliInputError, EmptyMetadata
 from aqt.helper import Settings
 from aqt.installer import Cli
-from aqt.metadata import (
-    ArchiveId,
-    MetadataFactory,
-    SimpleSpec,
-    ToolData,
-    Version,
-    Versions,
-    format_suggested_follow_up,
-    show_list,
-    suggested_follow_up,
-)
+from aqt.metadata import ArchiveId, MetadataFactory, SimpleSpec, ToolData, Version, Versions, show_list, suggested_follow_up
 
 Settings.load_settings()
 
@@ -345,7 +335,7 @@ qt6_android_requires_ext_msg = (
     "Please add your extension using the `--extension` flag."
 )
 no_arm64_v8_msg = "The extension 'arm64_v8a' is only valid for Qt 6 for Android"
-no_wasm_msg = "The extension 'wasm' is only available in Qt 5.13-5.15 and 6.2+ on desktop."
+no_wasm_msg = "The extension 'wasm' is only available in Qt 5.13 to 5.15 on desktop."
 
 
 @pytest.mark.parametrize(
@@ -356,10 +346,10 @@ no_wasm_msg = "The extension 'wasm' is only available in Qt 5.13-5.15 and 6.2+ o
         ("desktop", "arm64_v8a", "5.13.0", no_arm64_v8_msg),
         ("desktop", "arm64_v8a", "6.2.0", no_arm64_v8_msg),
         ("desktop", "wasm", "5.12.11", no_wasm_msg),  # out of range
-        ("desktop", "wasm", "6.1.9", no_wasm_msg),  # out of range
+        ("desktop", "wasm", "6.2.0", no_wasm_msg),  # out of range
         ("android", "wasm", "5.12.11", no_wasm_msg),  # in range, wrong target
         ("android", "wasm", "5.14.0", no_wasm_msg),  # in range, wrong target
-        ("android", "wasm", "6.1.9", qt6_android_requires_ext_msg),
+        ("android", "wasm", "6.2.0", qt6_android_requires_ext_msg),
     ),
 )
 def test_list_invalid_extensions(capsys, monkeypatch, target, ext, version, expected_msg):
@@ -452,12 +442,13 @@ def test_format_suggested_follow_up():
         "* Please use 'aqt list-tool mac desktop --extensions <QT_VERSION>' to list valid extensions.\n"
         "* Please use 'aqt list-tool mac desktop' to check what tools are available."
     )
-
-    assert format_suggested_follow_up(suggestions) == expected
+    ex = AqtException("msg", suggested_action=suggestions)
+    assert ex._format_suggested_follow_up() == expected
 
 
 def test_format_suggested_follow_up_empty():
-    assert format_suggested_follow_up([]) == ""
+    ex = AqtException("msg", suggested_action=[])
+    assert format(ex) == "msg"
 
 
 @pytest.mark.parametrize(
@@ -606,7 +597,7 @@ def test_show_list_tools_long_ifw(capsys, monkeypatch, columns, expect):
         tool_name="tools_ifw",
         is_long_listing=True,
     )
-    assert show_list(meta) == 0
+    show_list(meta)
     out, err = capsys.readouterr()
     sys.stdout.write(out)
     sys.stderr.write(err)
@@ -620,7 +611,7 @@ def test_show_list_versions(monkeypatch, capsys):
     expect_file = Path(__file__).parent / "data" / "mac-desktop-expect.json"
     expected = "\n".join(json.loads(expect_file.read_text("utf-8"))["qt"]["qt"]) + "\n"
 
-    assert show_list(MetadataFactory(mac_qt)) == 0
+    show_list(MetadataFactory(mac_qt))
     out, err = capsys.readouterr()
     assert out == expected
 
@@ -633,13 +624,44 @@ def test_show_list_tools(monkeypatch, capsys):
     expect = "\n".join(json.loads(expect_file.read_text("utf-8"))["tools"]) + "\n"
 
     meta = MetadataFactory(ArchiveId("tools", "mac", "desktop"))
-    assert show_list(meta) == 0
+    show_list(meta)
     out, err = capsys.readouterr()
     sys.stdout.write(out)
     sys.stderr.write(err)
     assert out == expect
 
 
+def test_show_list_empty(monkeypatch, capsys):
+    monkeypatch.setattr(MetadataFactory, "getList", lambda self: [])
+    meta = MetadataFactory(ArchiveId("tools", "mac", "desktop"))
+    with pytest.raises(EmptyMetadata) as error:
+        show_list(meta)
+    assert error.type == EmptyMetadata
+    assert format(error.value) == "No tools available for this request."
+
+
+def test_show_list_bad_connection(monkeypatch, capsys):
+    for exception_class, error_msg in (
+        (ArchiveConnectionError, "Failure to connect to some url"),
+        (ArchiveDownloadError, "Failure to download some xml file"),
+    ):
+
+        def mock(self):
+            raise exception_class(error_msg)
+
+        monkeypatch.setattr(MetadataFactory, "getList", mock)
+        meta = MetadataFactory(mac_wasm, spec=SimpleSpec("<5.9"))
+        with pytest.raises(exception_class) as error:
+            show_list(meta)
+        assert error.type == exception_class
+        assert format(error.value) == (
+            f"{error_msg}\n"
+            "==============================Suggested follow-up:==============================\n"
+            "* Please use 'aqt list-qt mac desktop --extensions <QT_VERSION>' to list valid extensions.\n"
+            "* Please use 'aqt list-qt mac desktop' to check that versions of qt exist within the spec '<5.9'."
+        )
+
+
 def fetch_expected_tooldata(json_filename: str) -> ToolData:
     text = (Path(__file__).parent / "data" / json_filename).read_text("utf-8")
     raw_tooldata: List[List[str]] = json.loads(text)["long_listing"]
diff --git a/tests/test_updater.py b/tests/test_updater.py
new file mode 100644
index 0000000..0899fbc
--- /dev/null
+++ b/tests/test_updater.py
@@ -0,0 +1,71 @@
+import re
+from tempfile import TemporaryDirectory
+
+import pytest
+from pytest_socket import disable_socket
+
+from aqt.archives import TargetConfig
+from aqt.exceptions import UpdaterError
+from aqt.helper import Settings
+from aqt.updater import Updater
+
+
+@pytest.fixture(autouse=True)
+def disable_sockets():
+    # This blocks all network connections, causing test failure if we used monkeypatch wrong
+    disable_socket()
+
+
+@pytest.fixture(autouse=True)
+def setup_settings():
+    Settings.load_settings()
+
+
+@pytest.mark.parametrize(
+    "target_config, expected_err_pattern",
+    (
+        (
+            TargetConfig("5.15.2", "winrt", "win64_msvc2019_winrt_x86", "windows"),
+            re.compile(
+                r"Updater caused an IO error: .*No such file or directory: "
+                # '.*' wildcard used to match path separators on windows/*nix
+                r".*5\.15\.2.*winrt_x86_msvc2019.*mkspecs.*qconfig.pri.*"
+            ),
+        ),
+        (
+            TargetConfig("5.15.2", "desktop", "win64_msvc2019_64", "windows"),
+            re.compile(
+                r"Updater caused an IO error: .*No such file or directory: "
+                # '.*' wildcard used to match path separators on windows/*nix
+                r".*5\.15\.2.*msvc2019_64.*mkspecs.*qconfig.pri.*"
+            ),
+        ),
+        (
+            TargetConfig("6.1.2", "desktop", "clang_64", "mac"),
+            re.compile(
+                r"Updater caused an IO error: .*No such file or directory: "
+                # '.*' wildcard used to match path separators on windows/*nix
+                r".*6\.1\.2.*macos.*mkspecs.*qconfig.pri.*"
+            ),
+        ),
+        (
+            TargetConfig("6.1.1", "desktop", "clang_64", "mac"),
+            re.compile(
+                r"Updater caused an IO error: .*No such file or directory: "
+                # '.*' wildcard used to match path separators on windows/*nix
+                r".*6\.1\.1.*clang_64.*mkspecs.*qconfig.pri.*"
+            ),
+        ),
+    ),
+)
+def test_updater_update_license_io_error(monkeypatch, target_config: TargetConfig, expected_err_pattern: re.Pattern):
+    """
+    All of these tests will raise IOError when they attempt to patch the license file.
+    """
+    with pytest.raises(UpdaterError) as err:
+        with TemporaryDirectory() as empty_dir:
+            # Try to update a Qt installation that does not exist
+            Updater.update(target_config, base_dir=empty_dir)
+    assert err.type == UpdaterError
+    err_msg = format(err.value)
+    assert expected_err_pattern.match(err_msg)