Repository: miurahr/aqtinstall
Pull request #305 (2021-07-11 05:59:31)

Introduce metadata package
Refactoring Versions and ListCommand class

- Introduce metadata module
  that has Version, Versions and ListCommand class

- Introduce custom Version class
    
    A Version class override semantic_version.Version class
    that accept qt versions and tools versions
    


miurahr: @ddalcino I'd like to introduce new module `metadata` that has your improvements and your copyright.
Also I've done several refactoring on it.

- Introduce Version class inherited from semantic_version.Version class that accept Qt versions and other tools versions, that has custom parser
- Introduce `Versions.__format__()` method to help output of object values that renamed from `pretty_print()` 
- other improvements
 
Could you, @ddalcino , review for it?
miurahr: > Not sure if you meant to change the behavior of aqt list tools, but fyi it now prints them as a space-separated list on a single line, rather than one tool per line.

Thank you for it. I adjusted it now.

Patch:
diff --git a/aqt/archives.py b/aqt/archives.py
index 4dd71f7..9326c8e 100644
--- a/aqt/archives.py
+++ b/aqt/archives.py
@@ -19,38 +19,15 @@
 # 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.
-import itertools
-import operator
+
 import posixpath
-import random
-import re
 import xml.etree.ElementTree as ElementTree
 from logging import getLogger
-from typing import (
-    Callable,
-    Dict,
-    Generator,
-    Iterable,
-    Iterator,
-    List,
-    Optional,
-    Tuple,
-    Union,
-)
-
-import bs4
-from semantic_version import SimpleSpec, Version
-from texttable import Texttable
-
-from aqt import helper
-from aqt.exceptions import (
-    ArchiveConnectionError,
-    ArchiveDownloadError,
-    ArchiveListError,
-    CliInputError,
-    NoPackageFound,
-)
-from aqt.helper import ArchiveId, Settings, getUrl, xml_to_modules
+from typing import Optional, Tuple
+
+from aqt.exceptions import ArchiveListError, NoPackageFound
+from aqt.helper import Settings, getUrl
+from aqt.metadata import SimpleSpec, Version
 
 
 class TargetConfig:
@@ -70,445 +47,6 @@ class TargetConfig:
         print(f"({self.version}, {self.target}, {self.arch}, {self.os_name})")
 
 
-class ListCommand:
-    """Encapsulate all parts of the `aqt list` command"""
-
-    # Inner helper classes
-    class Versions:
-        def __init__(
-            self,
-            versions: Union[None, Version, Iterable[Tuple[int, Iterable[Version]]]],
-        ):
-            if versions is None:
-                self.versions = list()
-            elif isinstance(versions, Version):
-                self.versions = [[versions]]
-            else:
-                self.versions: List[List[Version]] = [
-                    list(versions_iterator) for _, versions_iterator in versions
-                ]
-
-        def __str__(self) -> str:
-            return str(self.versions)
-
-        def pretty_print(self) -> str:
-            return "\n".join(
-                " ".join(
-                    ListCommand.Versions.stringify_ver(version)
-                    for version in minor_list
-                )
-                for minor_list in self.versions
-            )
-
-        def __bool__(self):
-            return len(self.versions) > 0 and len(self.versions[0]) > 0
-
-        def latest(self) -> Optional[Version]:
-            if not self:
-                return None
-            return self.versions[-1][-1]
-
-        @staticmethod
-        def stringify_ver(version: Version) -> str:
-            if version.prerelease:
-                return "{}.{}-preview".format(version.major, version.minor)
-            return str(version)
-
-    class ListOfStr:
-        def __init__(self, strings: List[str]):
-            self.strings = strings
-
-        def __str__(self):
-            return str(self.strings)
-
-        def pretty_print(self) -> str:
-            return " ".join(self.strings)
-
-        def __bool__(self):
-            return len(self.strings) > 0 and len(self.strings[0]) > 0
-
-    class Tools(ListOfStr):
-        def pretty_print(self) -> str:
-            return "\n".join(self.strings)
-
-    class Table:
-        def __init__(self, head: List[str], rows: List[List[str]], max_width: int = 0):
-            # max_width is set to 0 by default: this disables wrapping of text table cells
-            self.head = head
-            self.rows = rows
-            self.max_width = max_width
-
-        def pretty_print(self) -> str:
-            table = Texttable(max_width=self.max_width)
-            table.set_deco(Texttable.HEADER)
-            table.header(self.head)
-            table.add_rows(self.rows, header=False)
-            return table.draw()
-
-    def __init__(
-        self,
-        archive_id: ArchiveId,
-        *,
-        filter_minor: Optional[int] = None,
-        is_latest_version: bool = False,
-        modules_ver: Optional[str] = None,
-        extensions_ver: Optional[str] = None,
-        architectures_ver: Optional[str] = None,
-        tool_name: Optional[str] = None,
-        tool_long_listing: Optional[str] = None,
-    ):
-        """
-        Construct ListCommand.
-
-        :param filter_minor:        When set, the ListCommand will filter out all versions of
-                                    Qt that don't match this minor version.
-        :param is_latest_version:   When True, the ListCommand will find all versions of Qt
-                                    matching filters, and only print the most recent version
-        :param modules_ver:         Version of Qt for which to list modules
-        :param extensions_ver:      Version of Qt for which to list extensions
-        :param architectures_ver:   Version of Qt for which to list architectures
-        """
-        self.logger = getLogger("aqt.archives")
-        self.archive_id = archive_id
-        self.filter_minor = filter_minor
-
-        if archive_id.is_tools():
-            if tool_name:
-                self.request_type = "tool variant names"
-                self._action = lambda: self.fetch_tool_modules(tool_name)
-            elif tool_long_listing:
-                self.request_type = "tool long listing"
-                self._action = lambda: self.fetch_tool_long_listing(tool_long_listing)
-            else:
-                self.request_type = "tools"
-                self._action = self.fetch_tools
-        elif is_latest_version:
-            self.request_type = "latest version"
-            self._action = lambda: ListCommand.Versions(self.fetch_latest_version())
-        elif modules_ver:
-            self.request_type = "modules"
-            self._action = lambda: self.fetch_modules(self._to_version(modules_ver))
-        elif extensions_ver:
-            self.request_type = "extensions"
-            self._action = lambda: self.fetch_extensions(
-                self._to_version(extensions_ver)
-            )
-        elif architectures_ver:
-            self.request_type = "architectures"
-            self._action = lambda: self.fetch_arches(
-                self._to_version(architectures_ver)
-            )
-        else:
-            self.request_type = "versions"
-            self._action = self.fetch_versions
-
-    def action(self) -> Union[ListOfStr, Tools, Versions]:
-        return self._action()
-
-    def run(self) -> int:
-        try:
-            output = self.action()
-            if not output:
-                self.logger.info(
-                    "No {} available for this request.".format(self.request_type)
-                )
-                self.print_suggested_follow_up(self.logger.info)
-                return 1
-            print(output.pretty_print())
-            return 0
-        except CliInputError as e:
-            self.logger.error("Command line input error: {}".format(e))
-            return 1
-        except (ArchiveConnectionError, ArchiveDownloadError) as e:
-            self.logger.error("{}".format(e))
-            self.print_suggested_follow_up(self.logger.error)
-            return 1
-
-    def fetch_modules(self, version: Version) -> ListOfStr:
-        return self.get_modules_architectures_for_version(version=version)[0]
-
-    def fetch_arches(self, version: Version) -> ListOfStr:
-        return self.get_modules_architectures_for_version(version=version)[1]
-
-    def fetch_extensions(self, version: Version) -> ListOfStr:
-        versions_extensions = ListCommand.get_versions_extensions(
-            self.fetch_http(self.archive_id.to_url()), self.archive_id.category
-        )
-        filtered = filter(
-            lambda ver_ext: ver_ext[0] == version and ver_ext[1],
-            versions_extensions,
-        )
-        return ListCommand.ListOfStr(
-            strings=list(map(lambda ver_ext: ver_ext[1], filtered))
-        )
-
-    def fetch_versions(self) -> Versions:
-        def filter_by(ver_ext: Tuple[Optional[Version], str]) -> bool:
-            version, extension = ver_ext
-            return (
-                version
-                and (self.filter_minor is None or self.filter_minor == version.minor)
-                and (self.archive_id.extension == extension)
-            )
-
-        def get_version(ver_ext: Tuple[Version, str]):
-            return ver_ext[0]
-
-        versions_extensions = ListCommand.get_versions_extensions(
-            self.fetch_http(self.archive_id.to_url()), 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 ListCommand.Versions(iterables)
-
-    def fetch_latest_version(self) -> Optional[Version]:
-        return self.fetch_versions().latest()
-
-    def fetch_tools(self) -> Tools:
-        html_doc = self.fetch_http(self.archive_id.to_url())
-        return ListCommand.Tools(list(ListCommand.iterate_folders(html_doc, "tools")))
-
-    def _fetch_tool_data(
-        self, tool_name: str, keys_to_keep: Optional[Iterable[str]] = None
-    ) -> Dict[str, Dict[str, str]]:
-        # raises ArchiveDownloadError, ArchiveConnectionError
-        rest_of_url = self.archive_id.to_url() + tool_name + "/Updates.xml"
-        xml = self.fetch_http(rest_of_url)
-        modules = xml_to_modules(
-            xml,
-            predicate=ListCommand._has_nonempty_downloads,
-            keys_to_keep=keys_to_keep,
-        )
-        return modules
-
-    def fetch_tool_modules(self, tool_name: str) -> ListOfStr:
-        tool_data = self._fetch_tool_data(tool_name, keys_to_keep=())
-        return ListCommand.ListOfStr(strings=list(tool_data.keys()))
-
-    def fetch_tool_by_simple_spec(
-        self, tool_name: str, simple_spec: SimpleSpec
-    ) -> Optional[Dict[str, str]]:
-        # Get data for all the tool modules
-        all_tools_data = self._fetch_tool_data(tool_name)
-        return ListCommand.choose_highest_version_in_spec(all_tools_data, simple_spec)
-
-    def fetch_tool_long_listing(self, tool_name: str) -> Table:
-        head = [
-            "Tool Variant Name",
-            "Version",
-            "Release Date",
-            "Display Name",
-            "Description",
-        ]
-        keys = ("Version", "ReleaseDate", "DisplayName", "Description")
-        tool_data = self._fetch_tool_data(tool_name, keys_to_keep=keys)
-        rows = [
-            [name, *[content[key] for key in keys]]
-            for name, content in tool_data.items()
-        ]
-        return ListCommand.Table(head, rows)
-
-    @staticmethod
-    def choose_highest_version_in_spec(
-        all_tools_data: Dict[str, Dict[str, str]], simple_spec: SimpleSpec
-    ) -> Optional[Dict[str, str]]:
-        # Get versions of all modules. Fail if version cannot be determined.
-        try:
-            tools_versions = [
-                (name, tool_data, helper.to_version_permissive(tool_data["Version"]))
-                for name, tool_data in all_tools_data.items()
-            ]
-        except ValueError:
-            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
-        )
-
-        try:
-            # Return the conforming item with the highest version.
-            # If there are multiple items with the same version, the result will not be predictable.
-            return max(tools_versions, key=operator.itemgetter(2))[1]
-        except ValueError:
-            # There were no tools that fit the simple_spec
-            return None
-
-    def _to_version(self, qt_ver: str) -> Version:
-        """
-        Turns a string in the form of `5.X.Y | latest` into a semantic version.
-        If the string does not fit either of these forms, CliInputError will be raised.
-        If qt_ver == latest, and no versions exist corresponding to the filters specified,
-        then CliInputError will be raised.
-        If qt_ver == latest, and an HTTP error occurs, requests.RequestException will be raised.
-
-        :param qt_ver:  Either the literal string `latest`, or a semantic version
-                        with each part separated with dots.
-        """
-        assert qt_ver
-        if qt_ver == "latest":
-            latest_version = self.fetch_latest_version()
-            if not latest_version:
-                msg = "There is no latest version of Qt with the criteria '{}'".format(
-                    self.describe_filters()
-                )
-                raise CliInputError(msg)
-            return latest_version
-        version = helper.to_version(qt_ver)
-        if self.archive_id.is_major_ver_mismatch(version):
-            msg = "Major version mismatch between {} and {}".format(
-                self.archive_id.category, version
-            )
-            raise CliInputError(msg)
-        return version
-
-    @staticmethod
-    def fetch_http(rest_of_url: str) -> str:
-        base_urls = Settings.baseurl, random.choice(Settings.fallbacks)
-        for i, base_url in enumerate(base_urls):
-            try:
-                url = posixpath.join(base_url, rest_of_url)
-                return getUrl(
-                    url=url,
-                    timeout=(Settings.connection_timeout, Settings.response_timeout),
-                )
-
-            except (ArchiveDownloadError, ArchiveConnectionError) as e:
-                if i == len(base_urls) - 1:
-                    raise e
-
-    @staticmethod
-    def iterate_folders(
-        html_doc: str, filter_category: str = ""
-    ) -> Generator[str, None, None]:
-        def table_row_to_folder(tr: bs4.element.Tag) -> str:
-            try:
-                return tr.find_all("td")[1].a.contents[0].rstrip("/")
-            except (AttributeError, IndexError):
-                return ""
-
-        soup: bs4.BeautifulSoup = bs4.BeautifulSoup(html_doc, "html.parser")
-        for row in soup.body.table.find_all("tr"):
-            content: str = table_row_to_folder(row)
-            if not content or content == "Parent Directory":
-                continue
-            if content.startswith(filter_category):
-                yield content
-
-    @staticmethod
-    def get_versions_extensions(
-        html_doc: str, category: str
-    ) -> Iterator[Tuple[Optional[Version], str]]:
-        def folder_to_version_extension(folder: str) -> Tuple[Optional[Version], str]:
-            components = folder.split("_", maxsplit=2)
-            ext = "" if len(components) < 3 else components[2]
-            ver = "" if len(components) < 2 else components[1]
-            return (
-                helper.get_semantic_version(qt_ver=ver, is_preview="preview" in ext),
-                ext,
-            )
-
-        return map(
-            folder_to_version_extension, ListCommand.iterate_folders(html_doc, category)
-        )
-
-    @staticmethod
-    def _has_nonempty_downloads(element: ElementTree.Element) -> bool:
-        """Returns True if the element has an empty '<DownloadableArchives/>' tag"""
-        downloads = element.find("DownloadableArchives")
-        return downloads is not None and downloads.text
-
-    def get_modules_architectures_for_version(
-        self, version: Version
-    ) -> Tuple[ListOfStr, ListOfStr]:
-        """Returns [list of modules, list of architectures]"""
-        patch = (
-            ""
-            if version.prerelease or self.archive_id.is_preview()
-            else str(version.patch)
-        )
-        qt_ver_str = "{}{}{}".format(version.major, version.minor, patch)
-        # Example: re.compile(r"^(preview\.)?qt\.(qt5\.)?590\.(.+)$")
-        pattern = re.compile(
-            r"^(preview\.)?qt\.(qt"
-            + str(version.major)
-            + r"\.)?"
-            + qt_ver_str
-            + r"\.(.+)$"
-        )
-
-        def to_module_arch(name: str) -> Tuple[Optional[str], Optional[str]]:
-            _match = pattern.match(name)
-            if not _match:
-                return None, None
-            module_with_arch = _match.group(3)
-            if self.archive_id.is_no_arch() or "." not in module_with_arch:
-                return module_with_arch, None
-            module, arch = module_with_arch.rsplit(".", 1)
-            return module, arch
-
-        rest_of_url = self.archive_id.to_url(
-            qt_version_no_dots=qt_ver_str, file="Updates.xml"
-        )
-        xml = self.fetch_http(rest_of_url)  # raises RequestException
-
-        # We want the names of modules, regardless of architecture:
-        modules = xml_to_modules(
-            xml,
-            predicate=ListCommand._has_nonempty_downloads,
-            keys_to_keep=(),  # Just want names
-        )
-
-        def naive_modules_arches(
-            names: Iterable[str],
-        ) -> Tuple[ListCommand.ListOfStr, ListCommand.ListOfStr]:
-            modules_and_arches, _modules, arches = set(), set(), set()
-            for name in names:
-                # First term could be a module name or an architecture
-                first_term, arch = to_module_arch(name)
-                if first_term:
-                    modules_and_arches.add(first_term)
-                if arch:
-                    arches.add(arch)
-            for first_term in modules_and_arches:
-                if first_term not in arches:
-                    _modules.add(first_term)
-            return (
-                ListCommand.ListOfStr(strings=sorted(_modules)),
-                ListCommand.ListOfStr(strings=sorted(arches)),
-            )
-
-        return naive_modules_arches(modules.keys())
-
-    def describe_filters(self) -> str:
-        if self.filter_minor is None:
-            return str(self.archive_id)
-        return "{} with minor version {}".format(self.archive_id, self.filter_minor)
-
-    def print_suggested_follow_up(self, printer: Callable[[str], None]) -> None:
-        """Makes an informed guess at what the user got wrong, in the event of an error."""
-        base_cmd = "aqt {0.category} {0.host} {0.target}".format(self.archive_id)
-        if self.archive_id.extension:
-            msg = "Please use '{} --extensions <QT_VERSION>' to list valid extensions.".format(
-                base_cmd
-            )
-            printer(msg)
-
-        if self.archive_id.is_tools() and self.request_type == "tool variant names":
-            msg = "Please use '{}' to check what tools are available.".format(base_cmd)
-            printer(msg)
-        elif self.filter_minor is not None:
-            msg = "Please use '{}' to check that versions of {} exist with the minor version '{}'".format(
-                base_cmd, self.archive_id.category, self.filter_minor
-            )
-            printer(msg)
-        elif self.request_type in ("architectures", "modules", "extensions"):
-            msg = "Please use '{}' to show versions of Qt available".format(base_cmd)
-            printer(msg)
-
-
 class QtPackage:
     """
     Hold package information.
diff --git a/aqt/helper.py b/aqt/helper.py
index 26c0b39..261371f 100644
--- a/aqt/helper.py
+++ b/aqt/helper.py
@@ -24,7 +24,6 @@ import hashlib
 import json
 import logging.config
 import os
-import re
 import sys
 import xml.etree.ElementTree as ElementTree
 from logging import getLogger
@@ -34,83 +33,8 @@ from urllib.parse import urlparse
 
 import requests
 import requests.adapters
-from semantic_version import Version
-
-from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError, CliInputError
-
-
-class ArchiveId:
-    CATEGORIES = ("tools", "qt5", "qt6")
-    HOSTS = ("windows", "mac", "linux")
-    TARGETS_FOR_HOST = {
-        "windows": ["android", "desktop", "winrt"],
-        "mac": ["android", "desktop", "ios"],
-        "linux": ["android", "desktop"],
-    }
-    ALL_EXTENSIONS = (
-        "wasm",
-        "src_doc_examples",
-        "preview",
-        "wasm_preview",
-        "x86_64",
-        "x86",
-        "armv7",
-        "arm64_v8a",
-    )
-
-    def __init__(self, category: str, host: str, target: str, extension: str = ""):
-        if category not in ArchiveId.CATEGORIES:
-            raise ValueError("Category '{}' is invalid".format(category))
-        if host not in ArchiveId.HOSTS:
-            raise ValueError("Host '{}' is invalid".format(host))
-        if target not in ArchiveId.TARGETS_FOR_HOST[host]:
-            raise ValueError("Target '{}' is invalid".format(target))
-        if extension and extension not in ArchiveId.ALL_EXTENSIONS:
-            raise ValueError("Extension '{}' is invalid".format(extension))
-        self.category: str = category
-        self.host: str = host
-        self.target: str = target
-        self.extension: str = extension
-
-    def is_preview(self) -> bool:
-        return "preview" in self.extension if self.extension else False
-
-    def is_qt(self) -> bool:
-        return self.category.startswith("qt")
-
-    def is_tools(self) -> bool:
-        return self.category == "tools"
-
-    def is_no_arch(self) -> bool:
-        """Returns True if there should be no arch attached to the module names"""
-        return self.extension in ("src_doc_examples",)
-
-    def is_major_ver_mismatch(self, qt_version: Version) -> bool:
-        """Returns True if the version specifies a version different from the specified category"""
-        return self.is_qt() and int(self.category[-1]) != qt_version.major
-
-    def to_url(self, qt_version_no_dots: Optional[str] = None, file: str = "") -> str:
-        base = "online/qtsdkrepository/{os}{arch}/{target}/".format(
-            os=self.host,
-            arch="_x86" if self.host == "windows" else "_x64",
-            target=self.target,
-        )
-        if not qt_version_no_dots:
-            return base
-        folder = "{category}_{ver}{ext}/".format(
-            category=self.category,
-            ver=qt_version_no_dots,
-            ext="_" + self.extension if self.extension else "",
-        )
-        return base + folder + file
-
-    def __str__(self) -> str:
-        return "{cat}/{host}/{target}{ext}".format(
-            cat=self.category,
-            host=self.host,
-            target=self.target,
-            ext="" if not self.extension else "/" + self.extension,
-        )
+
+from aqt.exceptions import ArchiveConnectionError, ArchiveDownloadError
 
 
 def _get_meta(url: str):
@@ -276,83 +200,6 @@ class MyQueueListener(QueueListener):
         logger.handle(record)
 
 
-def to_version(qt_ver: str) -> Version:
-    """Converts a Qt version string with dots (5.X.Y, etc) into a semantic version.
-    If the version ends in `-preview`, the version is treated as a preview release.
-    If the patch value is missing, patch is assumed to be zero.
-    If the version cannot be converted to a Version, a CliInputError is raised.
-    """
-    match = re.match(r"^(\d+)\.(\d+)(\.(\d+)|-preview)$", qt_ver)
-    if not match:
-        raise CliInputError(
-            "Invalid version: '{}'! Please use the form '5.X.Y'.".format(qt_ver)
-        )
-    major, minor, end, patch = match.groups()
-    is_preview = end == "-preview"
-    return Version(
-        major=int(major),
-        minor=int(minor),
-        patch=int(patch) if patch else 0,
-        prerelease=("preview",) if is_preview else None,
-    )
-
-
-def to_version_permissive(version_str: str) -> Version:
-    """Converts a version string with dots (5.X.Y, etc) into a semantic version.
-    If the version omits either the patch or minor versions, they will be filled in with zeros,
-    and the remaining version string becomes part of the prerelease component.
-    If the version cannot be converted to a Version, a ValueError is raised.
-
-    This function is intended to be used on Version tags in an Updates.xml file.
-
-    to_version_permissive('1.33.1-202102101246') => Version('1.33.1-202102101246')
-    to_version_permissive('1.33-202102101246') => Version('1.33.0-202102101246')    # tools_conan
-    to_version_permissive('2020-05-19-1') => Version('2020.0.0-05-19-1')            # tools_vcredist
-    """
-    match = re.match(r"^(\d+)(\.(\d+)(\.(\d+))?)?(.*)$", version_str)
-    if not match:
-        raise ValueError("Invalid version detected: '{}'".format(version_str))
-
-    major, dot_minor, minor, dot_patch, patch, prerelease = match.groups()
-    return Version(
-        "{}.{}.{}{}".format(
-            major, minor if minor else 0, patch if patch else 0, prerelease
-        )
-    )
-
-
-def get_semantic_version(qt_ver: str, is_preview: bool) -> Optional[Version]:
-    """Converts a Qt version string (596, 512, 5132, etc) into a semantic version.
-    This makes a lot of assumptions based on established patterns:
-    If is_preview is True, the number is interpreted as ver[0].ver[1:], with no patch.
-    If the version is 3 digits, then major, minor, and patch each get 1 digit.
-    If the version is 4 or more digits, then major gets 1 digit, minor gets 2 digits
-    and patch gets all the rest.
-    As of May 2021, the version strings at https://download.qt.io/online/qtsdkrepository
-    conform to this pattern; they are not guaranteed to do so in the future.
-    """
-    if not qt_ver or any(not ch.isdigit() for ch in qt_ver):
-        return None
-    if is_preview:
-        return Version(
-            major=int(qt_ver[:1]),
-            minor=int(qt_ver[1:]),
-            patch=0,
-            prerelease=("preview",),
-        )
-    elif len(qt_ver) >= 4:
-        return Version(
-            major=int(qt_ver[:1]), minor=int(qt_ver[1:3]), patch=int(qt_ver[3:])
-        )
-    elif len(qt_ver) == 3:
-        return Version(
-            major=int(qt_ver[:1]), minor=int(qt_ver[1:2]), patch=int(qt_ver[2:])
-        )
-    elif len(qt_ver) == 2:
-        return Version(major=int(qt_ver[:1]), minor=int(qt_ver[1:2]), patch=0)
-    raise ValueError("Invalid version string '{}'".format(qt_ver))
-
-
 def xml_to_modules(
     xml_text: str,
     predicate: Callable[[ElementTree.Element], bool],
diff --git a/aqt/installer.py b/aqt/installer.py
index 9958464..89232e9 100644
--- a/aqt/installer.py
+++ b/aqt/installer.py
@@ -32,10 +32,8 @@ import time
 from logging import getLogger
 from logging.handlers import QueueHandler
 
-from semantic_version import Version
-
 import aqt
-from aqt.archives import ListCommand, QtArchives, SrcDocExamplesArchives, ToolArchives
+from aqt.archives import QtArchives, SrcDocExamplesArchives, ToolArchives
 from aqt.exceptions import (
     ArchiveConnectionError,
     ArchiveDownloadError,
@@ -43,13 +41,13 @@ from aqt.exceptions import (
     NoPackageFound,
 )
 from aqt.helper import (
-    ArchiveId,
     MyQueueListener,
     Settings,
     downloadBinaryFile,
     getUrl,
     setup_logging,
 )
+from aqt.metadata import ArchiveId, ListCommand, Version
 from aqt.updater import Updater
 
 try:
diff --git a/aqt/metadata.py b/aqt/metadata.py
new file mode 100644
index 0000000..1ffdda2
--- /dev/null
+++ b/aqt/metadata.py
@@ -0,0 +1,663 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2021 David Dalcino
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy of
+# this software and associated documentation files (the "Software"), to deal in
+# the Software without restriction, including without limitation the rights to
+# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+# the Software, and to permit persons to whom the Software is furnished to do so,
+# subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+# FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+# 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.
+
+import itertools
+import operator
+import posixpath
+import random
+import re
+from logging import getLogger
+from typing import (
+    Callable,
+    Dict,
+    Generator,
+    Iterable,
+    Iterator,
+    List,
+    Optional,
+    Tuple,
+    Union,
+)
+from xml.etree import ElementTree as ElementTree
+
+import bs4
+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.helper import Settings, getUrl, xml_to_modules
+
+
+class SimpleSpec(SemanticSimpleSpec):
+    pass
+
+
+class Version(SemanticVersion):
+    """Override semantic_version.Version class
+    to accept Qt versions and tools versions
+    If the version ends in `-preview`, the version is treated as a preview release.
+    """
+
+    def __init__(
+        self,
+        version_string=None,
+        major=None,
+        minor=None,
+        patch=None,
+        prerelease=None,
+        build=None,
+        partial=False,
+    ):
+        if version_string is None:
+            super(Version, self).__init__(
+                version_string=None,
+                major=major,
+                minor=minor,
+                patch=patch,
+                prerelease=prerelease,
+                build=build,
+                partial=partial,
+            )
+            return
+        # test qt versions
+        match = re.match(r"^(\d+)\.(\d+)(\.(\d+)|-preview)$", version_string)
+        if not match:
+            # bad input
+            raise ValueError("Invalid version string: '{}'".format(version_string))
+        major, minor, end, patch = match.groups()
+        is_preview = end == "-preview"
+        super(Version, self).__init__(
+            major=int(major),
+            minor=int(minor),
+            patch=int(patch) if patch else 0,
+            prerelease=("preview",) if is_preview else None,
+        )
+
+    def __str__(self):
+        if self.prerelease:
+            return "{}.{}-preview".format(self.major, self.minor)
+        return super(Version, self).__str__()
+
+    @classmethod
+    def permissive(cls, version_string: str):
+        """Converts a version string with dots (5.X.Y, etc) into a semantic version.
+        If the version omits either the patch or minor versions, they will be filled in with zeros,
+        and the remaining version string becomes part of the prerelease component.
+        If the version cannot be converted to a Version, a ValueError is raised.
+
+        This is intended to be used on Version tags in an Updates.xml file.
+
+        '1.33.1-202102101246' => Version('1.33.1-202102101246')
+        '1.33-202102101246' => Version('1.33.0-202102101246')    # tools_conan
+        '2020-05-19-1' => Version('2020.0.0-05-19-1')            # tools_vcredist
+        """
+
+        match = re.match(r"^(\d+)(\.(\d+)(\.(\d+))?)?(-(.+))?$", version_string)
+        if not match:
+            raise ValueError("Invalid version string: '{}'".format(version_string))
+        major, dot_minor, minor, dot_patch, patch, hyphen_build, build = match.groups()
+        return cls(
+            major=int(major),
+            minor=int(minor) if minor else 0,
+            patch=int(patch) if patch else 0,
+            build=(build,) if build else None,
+        )
+
+
+class Versions:
+    def __init__(
+        self,
+        versions: Union[None, Version, Iterable[Tuple[int, Iterable[Version]]]],
+    ):
+        if versions is None:
+            self.versions = list()
+        elif isinstance(versions, Version):
+            self.versions = [[versions]]
+        else:
+            self.versions: List[List[Version]] = [
+                list(versions_iterator) for _, versions_iterator in versions
+            ]
+
+    def __str__(self) -> str:
+        return str(self.versions)
+
+    def __format__(self, format_spec) -> str:
+        if format_spec == "":
+            return "\n".join(
+                " ".join(str(version) for version in minor_list)
+                for minor_list in self.versions
+            )
+        elif format_spec == "s":
+            return str(self.versions)
+        else:
+            raise TypeError("Unsupported format.")
+
+    def __bool__(self):
+        return len(self.versions) > 0 and len(self.versions[0]) > 0
+
+    def latest(self) -> Optional[Version]:
+        if not self:
+            return None
+        return self.versions[-1][-1]
+
+    def __iter__(self) -> List[Version]:
+        for item in self.versions:
+            yield item
+
+
+def get_semantic_version(qt_ver: str, is_preview: bool) -> Optional[Version]:
+    """Converts a Qt version string (596, 512, 5132, etc) into a semantic version.
+    This makes a lot of assumptions based on established patterns:
+    If is_preview is True, the number is interpreted as ver[0].ver[1:], with no patch.
+    If the version is 3 digits, then major, minor, and patch each get 1 digit.
+    If the version is 4 or more digits, then major gets 1 digit, minor gets 2 digits
+    and patch gets all the rest.
+    As of May 2021, the version strings at https://download.qt.io/online/qtsdkrepository
+    conform to this pattern; they are not guaranteed to do so in the future.
+    """
+    if not qt_ver or any(not ch.isdigit() for ch in qt_ver):
+        return None
+    if is_preview:
+        return Version(
+            major=int(qt_ver[:1]),
+            minor=int(qt_ver[1:]),
+            patch=0,
+            prerelease=("preview",),
+        )
+    elif len(qt_ver) >= 4:
+        return Version(
+            major=int(qt_ver[:1]), minor=int(qt_ver[1:3]), patch=int(qt_ver[3:])
+        )
+    elif len(qt_ver) == 3:
+        return Version(
+            major=int(qt_ver[:1]), minor=int(qt_ver[1:2]), patch=int(qt_ver[2:])
+        )
+    elif len(qt_ver) == 2:
+        return Version(major=int(qt_ver[:1]), minor=int(qt_ver[1:2]), patch=0)
+    raise ValueError("Invalid version string '{}'".format(qt_ver))
+
+
+class ArchiveId:
+    CATEGORIES = ("tools", "qt5", "qt6")
+    HOSTS = ("windows", "mac", "linux")
+    TARGETS_FOR_HOST = {
+        "windows": ["android", "desktop", "winrt"],
+        "mac": ["android", "desktop", "ios"],
+        "linux": ["android", "desktop"],
+    }
+    ALL_EXTENSIONS = (
+        "wasm",
+        "src_doc_examples",
+        "preview",
+        "wasm_preview",
+        "x86_64",
+        "x86",
+        "armv7",
+        "arm64_v8a",
+    )
+
+    def __init__(self, category: str, host: str, target: str, extension: str = ""):
+        if category not in ArchiveId.CATEGORIES:
+            raise ValueError("Category '{}' is invalid".format(category))
+        if host not in ArchiveId.HOSTS:
+            raise ValueError("Host '{}' is invalid".format(host))
+        if target not in ArchiveId.TARGETS_FOR_HOST[host]:
+            raise ValueError("Target '{}' is invalid".format(target))
+        if extension and extension not in ArchiveId.ALL_EXTENSIONS:
+            raise ValueError("Extension '{}' is invalid".format(extension))
+        self.category: str = category
+        self.host: str = host
+        self.target: str = target
+        self.extension: str = extension
+
+    def is_preview(self) -> bool:
+        return "preview" in self.extension if self.extension else False
+
+    def is_qt(self) -> bool:
+        return self.category.startswith("qt")
+
+    def is_tools(self) -> bool:
+        return self.category == "tools"
+
+    def is_no_arch(self) -> bool:
+        """Returns True if there should be no arch attached to the module names"""
+        return self.extension in ("src_doc_examples",)
+
+    def is_major_ver_mismatch(self, qt_version: Version) -> bool:
+        """Returns True if the version specifies a version different from the specified category"""
+        return self.is_qt() and int(self.category[-1]) != qt_version.major
+
+    def to_url(self, qt_version_no_dots: Optional[str] = None, file: str = "") -> str:
+        base = "online/qtsdkrepository/{os}{arch}/{target}/".format(
+            os=self.host,
+            arch="_x86" if self.host == "windows" else "_x64",
+            target=self.target,
+        )
+        if not qt_version_no_dots:
+            return base
+        folder = "{category}_{ver}{ext}/".format(
+            category=self.category,
+            ver=qt_version_no_dots,
+            ext="_" + self.extension if self.extension else "",
+        )
+        return base + folder + file
+
+    def __str__(self) -> str:
+        return "{cat}/{host}/{target}{ext}".format(
+            cat=self.category,
+            host=self.host,
+            target=self.target,
+            ext="" if not self.extension else "/" + self.extension,
+        )
+
+
+class Table:
+    def __init__(self, head: List[str], rows: List[List[str]], max_width: int = 0):
+        # max_width is set to 0 by default: this disables wrapping of text table cells
+        self.head = head
+        self.rows = rows
+        self.max_width = max_width
+
+    def __format__(self, format_spec) -> str:
+        if format_spec == "":
+            table = Texttable(max_width=self.max_width)
+            table.set_deco(Texttable.HEADER)
+            table.header(self.head)
+            table.add_rows(self.rows, header=False)
+            return table.draw()
+        elif format_spec == "s":
+            return str(self)
+        else:
+            raise ValueError()
+
+
+class ListCommand:
+    """Encapsulate all parts of the `aqt list` command"""
+
+    def __init__(
+        self,
+        archive_id: ArchiveId,
+        *,
+        filter_minor: Optional[int] = None,
+        is_latest_version: bool = False,
+        modules_ver: Optional[str] = None,
+        extensions_ver: Optional[str] = None,
+        architectures_ver: Optional[str] = None,
+        tool_name: Optional[str] = None,
+        tool_long_listing: Optional[str] = None,
+    ):
+        """
+        Construct ListCommand.
+
+        :param filter_minor:        When set, the ListCommand will filter out all versions of
+                                    Qt that don't match this minor version.
+        :param is_latest_version:   When True, the ListCommand will find all versions of Qt
+                                    matching filters, and only print the most recent version
+        :param modules_ver:         Version of Qt for which to list modules
+        :param extensions_ver:      Version of Qt for which to list extensions
+        :param architectures_ver:   Version of Qt for which to list architectures
+        """
+        self.logger = getLogger("aqt.archives")
+        self.archive_id = archive_id
+        self.filter_minor = filter_minor
+
+        if archive_id.is_tools():
+            if tool_name:
+                self.request_type = "tool variant names"
+                self._action = lambda: self.fetch_tool_modules(tool_name)
+            elif tool_long_listing:
+                self.request_type = "tool long listing"
+                self._action = lambda: self.fetch_tool_long_listing(tool_long_listing)
+            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())
+        elif modules_ver:
+            self.request_type = "modules"
+            self._action = lambda: self.fetch_modules(self._to_version(modules_ver))
+        elif extensions_ver:
+            self.request_type = "extensions"
+            self._action = lambda: self.fetch_extensions(
+                self._to_version(extensions_ver)
+            )
+        elif architectures_ver:
+            self.request_type = "architectures"
+            self._action = lambda: self.fetch_arches(
+                self._to_version(architectures_ver)
+            )
+        else:
+            self.request_type = "versions"
+            self._action = self.fetch_versions
+
+    def action(self) -> Union[List[str], Versions, Table]:
+        return self._action()
+
+    def run(self) -> int:
+        try:
+            output = self.action()
+            if not output:
+                self.logger.info(
+                    "No {} available for this request.".format(self.request_type)
+                )
+                self.print_suggested_follow_up(self.logger.info)
+                return 1
+            if isinstance(output, Versions) or isinstance(output, Table):
+                print(format(output))
+            elif self.archive_id.is_tools():
+                print(*output, sep="\n")
+            else:
+                print(*output, sep=" ")
+            return 0
+        except CliInputError as e:
+            self.logger.error("Command line input error: {}".format(e))
+            return 1
+        except (ArchiveConnectionError, ArchiveDownloadError) as e:
+            self.logger.error("{}".format(e))
+            self.print_suggested_follow_up(self.logger.error)
+            return 1
+
+    def fetch_modules(self, version: Version) -> List[str]:
+        return self.get_modules_architectures_for_version(version=version)[0]
+
+    def fetch_arches(self, version: Version) -> List[str]:
+        return self.get_modules_architectures_for_version(version=version)[1]
+
+    def fetch_extensions(self, version: Version) -> List[str]:
+        versions_extensions = ListCommand.get_versions_extensions(
+            self.fetch_http(self.archive_id.to_url()), self.archive_id.category
+        )
+        filtered = filter(
+            lambda ver_ext: ver_ext[0] == version and ver_ext[1],
+            versions_extensions,
+        )
+        return list(map(lambda ver_ext: ver_ext[1], filtered))
+
+    def fetch_versions(self) -> Versions:
+        def filter_by(ver_ext: Tuple[Optional[Version], str]) -> bool:
+            version, extension = ver_ext
+            return (
+                version
+                and (self.filter_minor is None or self.filter_minor == version.minor)
+                and (self.archive_id.extension == extension)
+            )
+
+        def get_version(ver_ext: Tuple[Version, str]):
+            return ver_ext[0]
+
+        versions_extensions = ListCommand.get_versions_extensions(
+            self.fetch_http(self.archive_id.to_url()), 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)
+
+    def fetch_latest_version(self) -> Optional[Version]:
+        return self.fetch_versions().latest()
+
+    def fetch_tools(self) -> List[str]:
+        html_doc = self.fetch_http(self.archive_id.to_url())
+        return list(ListCommand.iterate_folders(html_doc, "tools"))
+
+    def _fetch_tool_data(
+        self, tool_name: str, keys_to_keep: Optional[Iterable[str]] = None
+    ) -> Dict[str, Dict[str, str]]:
+        # raises ArchiveDownloadError, ArchiveConnectionError
+        rest_of_url = self.archive_id.to_url() + tool_name + "/Updates.xml"
+        xml = self.fetch_http(rest_of_url)
+        modules = xml_to_modules(
+            xml,
+            predicate=ListCommand._has_nonempty_downloads,
+            keys_to_keep=keys_to_keep,
+        )
+        return modules
+
+    def fetch_tool_modules(self, tool_name: str) -> List[str]:
+        tool_data = self._fetch_tool_data(tool_name, keys_to_keep=())
+        return list(tool_data.keys())
+
+    def fetch_tool_by_simple_spec(
+        self, tool_name: str, simple_spec: SimpleSpec
+    ) -> Optional[Dict[str, str]]:
+        # Get data for all the tool modules
+        all_tools_data = self._fetch_tool_data(tool_name)
+        return ListCommand.choose_highest_version_in_spec(all_tools_data, simple_spec)
+
+    def fetch_tool_long_listing(self, tool_name: str) -> Table:
+        head = [
+            "Tool Variant Name",
+            "Version",
+            "Release Date",
+            "Display Name",
+            "Description",
+        ]
+        keys = ("Version", "ReleaseDate", "DisplayName", "Description")
+        tool_data = self._fetch_tool_data(tool_name, keys_to_keep=keys)
+        rows = [
+            [name, *[content[key] for key in keys]]
+            for name, content in tool_data.items()
+        ]
+        return Table(head, rows)
+
+    @staticmethod
+    def choose_highest_version_in_spec(
+        all_tools_data: Dict[str, Dict[str, str]], simple_spec: SimpleSpec
+    ) -> Optional[Dict[str, str]]:
+        # Get versions of all modules. Fail if version cannot be determined.
+        try:
+            tools_versions = [
+                (name, tool_data, Version.permissive(tool_data["Version"]))
+                for name, tool_data in all_tools_data.items()
+            ]
+        except ValueError:
+            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
+        )
+
+        try:
+            # Return the conforming item with the highest version.
+            # If there are multiple items with the same version, the result will not be predictable.
+            return max(tools_versions, key=operator.itemgetter(2))[1]
+        except ValueError:
+            # There were no tools that fit the simple_spec
+            return None
+
+    def _to_version(self, qt_ver: str) -> Version:
+        """
+        Turns a string in the form of `5.X.Y | latest` into a semantic version.
+        If the string does not fit either of these forms, CliInputError will be raised.
+        If qt_ver == latest, and no versions exist corresponding to the filters specified,
+        then CliInputError will be raised.
+        If qt_ver == latest, and an HTTP error occurs, requests.RequestException will be raised.
+
+        :param qt_ver:  Either the literal string `latest`, or a semantic version
+                        with each part separated with dots.
+        """
+        assert qt_ver
+        if qt_ver == "latest":
+            latest_version = self.fetch_latest_version()
+            if not latest_version:
+                msg = "There is no latest version of Qt with the criteria '{}'".format(
+                    self.describe_filters()
+                )
+                raise CliInputError(msg)
+            return latest_version
+        try:
+            version = Version(qt_ver)
+        except ValueError as e:
+            raise CliInputError(e)
+        if self.archive_id.is_major_ver_mismatch(version):
+            msg = "Major version mismatch between {} and {}".format(
+                self.archive_id.category, version
+            )
+            raise CliInputError(msg)
+        return version
+
+    @staticmethod
+    def fetch_http(rest_of_url: str) -> str:
+        base_urls = Settings.baseurl, random.choice(Settings.fallbacks)
+        for i, base_url in enumerate(base_urls):
+            try:
+                url = posixpath.join(base_url, rest_of_url)
+                return getUrl(
+                    url=url,
+                    timeout=(Settings.connection_timeout, Settings.response_timeout),
+                )
+
+            except (ArchiveDownloadError, ArchiveConnectionError) as e:
+                if i == len(base_urls) - 1:
+                    raise e
+
+    @staticmethod
+    def iterate_folders(
+        html_doc: str, filter_category: str = ""
+    ) -> Generator[str, None, None]:
+        def table_row_to_folder(tr: bs4.element.Tag) -> str:
+            try:
+                return tr.find_all("td")[1].a.contents[0].rstrip("/")
+            except (AttributeError, IndexError):
+                return ""
+
+        soup: bs4.BeautifulSoup = bs4.BeautifulSoup(html_doc, "html.parser")
+        for row in soup.body.table.find_all("tr"):
+            content: str = table_row_to_folder(row)
+            if not content or content == "Parent Directory":
+                continue
+            if content.startswith(filter_category):
+                yield content
+
+    @staticmethod
+    def get_versions_extensions(
+        html_doc: str, category: str
+    ) -> Iterator[Tuple[Optional[Version], str]]:
+        def folder_to_version_extension(folder: str) -> Tuple[Optional[Version], str]:
+            components = folder.split("_", maxsplit=2)
+            ext = "" if len(components) < 3 else components[2]
+            ver = "" if len(components) < 2 else components[1]
+            return (
+                get_semantic_version(qt_ver=ver, is_preview="preview" in ext),
+                ext,
+            )
+
+        return map(
+            folder_to_version_extension, ListCommand.iterate_folders(html_doc, category)
+        )
+
+    @staticmethod
+    def _has_nonempty_downloads(element: ElementTree.Element) -> bool:
+        """Returns True if the element has an empty '<DownloadableArchives/>' tag"""
+        downloads = element.find("DownloadableArchives")
+        return downloads is not None and downloads.text
+
+    def get_modules_architectures_for_version(
+        self, version: Version
+    ) -> Tuple[List[str], List[str]]:
+        """Returns [list of modules, list of architectures]"""
+        patch = (
+            ""
+            if version.prerelease or self.archive_id.is_preview()
+            else str(version.patch)
+        )
+        qt_ver_str = "{}{}{}".format(version.major, version.minor, patch)
+        # Example: re.compile(r"^(preview\.)?qt\.(qt5\.)?590\.(.+)$")
+        pattern = re.compile(
+            r"^(preview\.)?qt\.(qt"
+            + str(version.major)
+            + r"\.)?"
+            + qt_ver_str
+            + r"\.(.+)$"
+        )
+
+        def to_module_arch(name: str) -> Tuple[Optional[str], Optional[str]]:
+            _match = pattern.match(name)
+            if not _match:
+                return None, None
+            module_with_arch = _match.group(3)
+            if self.archive_id.is_no_arch() or "." not in module_with_arch:
+                return module_with_arch, None
+            module, arch = module_with_arch.rsplit(".", 1)
+            return module, arch
+
+        rest_of_url = self.archive_id.to_url(
+            qt_version_no_dots=qt_ver_str, file="Updates.xml"
+        )
+        xml = self.fetch_http(rest_of_url)  # raises RequestException
+
+        # We want the names of modules, regardless of architecture:
+        modules = xml_to_modules(
+            xml,
+            predicate=ListCommand._has_nonempty_downloads,
+            keys_to_keep=(),  # Just want names
+        )
+
+        def naive_modules_arches(
+            names: Iterable[str],
+        ) -> Tuple[List[str], List[str]]:
+            modules_and_arches, _modules, arches = set(), set(), set()
+            for name in names:
+                # First term could be a module name or an architecture
+                first_term, arch = to_module_arch(name)
+                if first_term:
+                    modules_and_arches.add(first_term)
+                if arch:
+                    arches.add(arch)
+            for first_term in modules_and_arches:
+                if first_term not in arches:
+                    _modules.add(first_term)
+            return (
+                sorted(_modules),
+                sorted(arches),
+            )
+
+        return naive_modules_arches(modules.keys())
+
+    def describe_filters(self) -> str:
+        if self.filter_minor is None:
+            return str(self.archive_id)
+        return "{} with minor version {}".format(self.archive_id, self.filter_minor)
+
+    def print_suggested_follow_up(self, printer: Callable[[str], None]) -> None:
+        """Makes an informed guess at what the user got wrong, in the event of an error."""
+        base_cmd = "aqt {0.category} {0.host} {0.target}".format(self.archive_id)
+        if self.archive_id.extension:
+            msg = "Please use '{} --extensions <QT_VERSION>' to list valid extensions.".format(
+                base_cmd
+            )
+            printer(msg)
+
+        if self.archive_id.is_tools() and self.request_type == "tool variant names":
+            msg = "Please use '{}' to check what tools are available.".format(base_cmd)
+            printer(msg)
+        elif self.filter_minor is not None:
+            msg = "Please use '{}' to check that versions of {} exist with the minor version '{}'".format(
+                base_cmd, self.archive_id.category, self.filter_minor
+            )
+            printer(msg)
+        elif self.request_type in ("architectures", "modules", "extensions"):
+            msg = "Please use '{}' to show versions of Qt available".format(base_cmd)
+            printer(msg)
diff --git a/aqt/updater.py b/aqt/updater.py
index 47ecbf2..9a8b6c3 100644
--- a/aqt/updater.py
+++ b/aqt/updater.py
@@ -25,9 +25,9 @@ import subprocess
 from logging import getLogger
 
 import patch
-from semantic_version import SimpleSpec, Version
 
 from aqt.helper import Settings
+from aqt.metadata import SimpleSpec, Version
 
 
 class Updater:


Test patch:
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 9c38e33..ce66280 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -2,9 +2,9 @@ import re
 import sys
 
 import pytest
-from semantic_version import Version
 
-import aqt
+from aqt.installer import Cli
+from aqt.metadata import Version
 
 
 def test_cli_help(capsys):
@@ -27,14 +27,14 @@ def test_cli_help(capsys):
             "                        subcommand for aqt Qt installer\n",
         ]
     )
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli.run(["help"])
     out, err = capsys.readouterr()
     assert out == expected
 
 
 def test_cli_check_module():
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli._setup_settings()
     assert cli._check_modules_arg("5.11.3", ["qtcharts", "qtwebengine"])
     assert not cli._check_modules_arg("5.7", ["not_exist"])
@@ -43,7 +43,7 @@ def test_cli_check_module():
 
 
 def test_cli_check_combination():
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli._setup_settings()
     assert cli._check_qt_arg_combination("5.11.3", "linux", "desktop", "gcc_64")
     assert cli._check_qt_arg_combination("5.11.3", "mac", "desktop", "clang_64")
@@ -51,7 +51,7 @@ def test_cli_check_combination():
 
 
 def test_cli_check_version():
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli._setup_settings()
     assert cli._check_qt_arg_versions("5.12.0")
     assert not cli._check_qt_arg_versions("5.12")
@@ -64,11 +64,11 @@ def test_cli_check_version():
 def test_cli_invalid_version(capsys, invalid_version):
     """Checks that invalid version strings are handled properly"""
 
-    # Ensure that invalid_version cannot be a Version
+    # Ensure that invalid_version cannot be a semantic_version.Version
     with pytest.raises(ValueError):
         Version(invalid_version)
 
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli._setup_settings()
 
     matcher = re.compile(
@@ -84,7 +84,7 @@ def test_cli_invalid_version(capsys, invalid_version):
         ("list", "qt5", "mac", "desktop", "--modules", invalid_version),
     ):
         with pytest.raises(SystemExit) as pytest_wrapped_e:
-            cli = aqt.installer.Cli()
+            cli = Cli()
             cli.run(cmd)
         assert pytest_wrapped_e.type == SystemExit
         assert pytest_wrapped_e.value.code == 1
@@ -95,7 +95,7 @@ def test_cli_invalid_version(capsys, invalid_version):
 
 
 def test_cli_check_mirror():
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli._setup_settings()
     assert cli._check_mirror(None)
     arg = ["install", "5.11.3", "linux", "desktop", "-b", "https://download.qt.io/"]
@@ -124,7 +124,7 @@ def test_cli_launch_with_no_argument(capsys):
             "                        subcommand for aqt Qt installer\n",
         ]
     )
-    cli = aqt.installer.Cli()
+    cli = Cli()
     cli.run([])
     out, err = capsys.readouterr()
     assert out == expected
diff --git a/tests/test_helper.py b/tests/test_helper.py
index 0535994..b807d35 100644
--- a/tests/test_helper.py
+++ b/tests/test_helper.py
@@ -4,9 +4,9 @@ import os
 import pytest
 import requests
 from requests.models import Response
-from semantic_version import Version
 
 from aqt import helper
+from aqt.metadata import Version
 
 
 def test_helper_altlink(monkeypatch):
@@ -102,11 +102,17 @@ def test_helper_downloadBinary_sha256(tmp_path, monkeypatch):
 @pytest.mark.parametrize(
     "version, expect",
     [
-        ("1.33.1", Version("1.33.1")),
-        ("1.33.1-202102101246", Version("1.33.1-202102101246")),
-        ("1.33-202102101246", Version("1.33.0-202102101246")),
-        ("2020-05-19-1", Version("2020.0.0-05-19-1")),
+        ("1.33.1", Version(major=1, minor=33, patch=1)),
+        (
+            "1.33.1-202102101246",
+            Version(major=1, minor=33, patch=1, build=("202102101246",)),
+        ),
+        (
+            "1.33-202102101246",
+            Version(major=1, minor=33, patch=0, build=("202102101246",)),
+        ),
+        ("2020-05-19-1", Version(major=2020, minor=0, patch=0, build=("05-19-1",))),
     ],
 )
 def test_helper_to_version_permissive(version, expect):
-    assert helper.to_version_permissive(version) == expect
+    assert Version.permissive(version) == expect
diff --git a/tests/test_list.py b/tests/test_list.py
index 1b3e993..45beeda 100644
--- a/tests/test_list.py
+++ b/tests/test_list.py
@@ -3,11 +3,9 @@ import re
 from pathlib import Path
 
 import pytest
-from semantic_version import SimpleSpec, Version
 
-from aqt import archives, installer
-from aqt.archives import ListCommand
-from aqt.helper import ArchiveId
+from aqt.installer import Cli
+from aqt.metadata import ArchiveId, ListCommand, SimpleSpec, Version
 
 MINOR_REGEX = re.compile(r"^\d+\.(\d+)")
 
@@ -27,7 +25,7 @@ MINOR_REGEX = re.compile(r"^\d+\.(\d+)")
 )
 def test_list_versions_tools(monkeypatch, os_name, target, in_file, expect_out_file):
     _html = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
-    monkeypatch.setattr(archives.ListCommand, "fetch_http", lambda self, _: _html)
+    monkeypatch.setattr(ListCommand, "fetch_http", lambda self, _: _html)
 
     expected = json.loads(
         (Path(__file__).parent / "data" / expect_out_file).read_text("utf-8")
@@ -35,7 +33,7 @@ def test_list_versions_tools(monkeypatch, os_name, target, in_file, expect_out_f
 
     # Test 'aqt list tools'
     tools = ListCommand(ArchiveId("tools", os_name, target)).action()
-    assert tools.pretty_print() == "\n".join(expected["tools"])
+    assert tools == expected["tools"]
 
     for qt in ("qt5", "qt6"):
         for ext, expected_output in expected[qt].items():
@@ -46,7 +44,7 @@ def test_list_versions_tools(monkeypatch, os_name, target, in_file, expect_out_f
             if len(expected_output) == 0:
                 assert not all_versions
             else:
-                assert all_versions.pretty_print() == "\n".join(expected_output)
+                assert f"{all_versions}" == "\n".join(expected_output)
 
             # Filter for the latest version only
             latest_ver = ListCommand(archive_id, is_latest_version=True).action()
@@ -54,7 +52,7 @@ def test_list_versions_tools(monkeypatch, os_name, target, in_file, expect_out_f
             if len(expected_output) == 0:
                 assert not latest_ver
             else:
-                assert latest_ver.pretty_print() == expected_output[-1].split(" ")[-1]
+                assert f"{latest_ver}" == expected_output[-1].split(" ")[-1]
 
             for row in expected_output:
                 minor = int(MINOR_REGEX.search(row).group(1))
@@ -65,14 +63,14 @@ def test_list_versions_tools(monkeypatch, os_name, target, in_file, expect_out_f
                     filter_minor=minor,
                     is_latest_version=True,
                 ).action()
-                assert latest_ver_for_minor.pretty_print() == row.split(" ")[-1]
+                assert f"{latest_ver_for_minor}" == row.split(" ")[-1]
 
                 # Find all versions for a particular minor version
                 all_ver_for_minor = ListCommand(
                     archive_id,
                     filter_minor=minor,
                 ).action()
-                assert all_ver_for_minor.pretty_print() == row
+                assert f"{all_ver_for_minor}" == row
 
 
 @pytest.mark.parametrize(
@@ -97,13 +95,13 @@ def test_list_architectures_and_modules(
         (Path(__file__).parent / "data" / expect_out_file).read_text("utf-8")
     )
 
-    monkeypatch.setattr(archives.ListCommand, "fetch_http", lambda self, _: _xml)
+    monkeypatch.setattr(ListCommand, "fetch_http", lambda self, _: _xml)
 
     modules = ListCommand(archive_id).fetch_modules(Version(version))
-    assert modules.strings == expect["modules"]
+    assert modules == expect["modules"]
 
     arches = ListCommand(archive_id).fetch_arches(Version(version))
-    assert arches.strings == expect["architectures"]
+    assert arches == expect["architectures"]
 
 
 @pytest.mark.parametrize(
@@ -123,10 +121,10 @@ def test_tool_modules(monkeypatch, host: str, target: str, tool_name: str):
         (Path(__file__).parent / "data" / expect_out_file).read_text("utf-8")
     )
 
-    monkeypatch.setattr(archives.ListCommand, "fetch_http", lambda self, _: _xml)
+    monkeypatch.setattr(ListCommand, "fetch_http", lambda self, _: _xml)
 
     modules = ListCommand(archive_id).fetch_tool_modules(tool_name)
-    assert modules.strings == expect["modules"]
+    assert modules == expect["modules"]
 
 
 @pytest.mark.parametrize(
@@ -146,7 +144,7 @@ def test_tool_long_listing(monkeypatch, host: str, target: str, tool_name: str):
         (Path(__file__).parent / "data" / expect_out_file).read_text("utf-8")
     )
 
-    monkeypatch.setattr(archives.ListCommand, "fetch_http", lambda self, _: _xml)
+    monkeypatch.setattr(ListCommand, "fetch_http", lambda self, _: _xml)
 
     table = ListCommand(archive_id).fetch_tool_long_listing(tool_name)
     assert table.rows == expect["long_listing"]
@@ -197,7 +195,7 @@ def test_list_cli(
         ver_to_replace = ver.replace(".", "")
         return text.replace(ver_to_replace, desired_version)
 
-    monkeypatch.setattr(archives.ListCommand, "fetch_http", _mock)
+    monkeypatch.setattr(ListCommand, "fetch_http", _mock)
 
     expected_modules_arches = json.loads(
         (Path(__file__).parent / "data" / xmlexpect).read_text("utf-8")
@@ -221,7 +219,7 @@ def test_list_cli(
     _minor = ["--filter-minor", minor_ver]
     _ext = ["--extension", ext]
 
-    cli = installer.Cli()
+    cli = Cli()
     # Query extensions by latest version, minor version, and specific version
     cli.run(["list", cat, host, target, "--extensions", "latest"])
     check_extensions()