files: #!/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 binascii
  import itertools
  import operator
  import posixpath
  import random
  import re
  import shutil
  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, ArchiveListError, CliInputError, EmptyMetadata
  from aqt.helper import Settings, get_hash, getUrl, xml_to_modules
  
  
  class SimpleSpec(SemanticSimpleSpec):
      pass
  
      @staticmethod
      def usage() -> str:
          return (
              "See documentation at: "
              "https://python-semanticversion.readthedocs.io/en/latest/reference.html#semantic_version.SimpleSpec\n"
              "Examples:\n"
              '* "*": matches everything\n'
              '* "5": matches every version with major=5\n'
              '* "5.6": matches every version beginning with 5.6\n'
              '* "5.*.3": matches versions with major=5 and patch=3'
          )
  
  
  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) -> Generator[List[Version], None, None]:
          for item in self.versions:
              yield item
  
      def flattened(self) -> List[Version]:
          """Return a flattened list of all versions"""
          return [version for row in self for version in row]
  
  
  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", "qt")
      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",
      )
      EXTENSIONS_REQUIRED_ANDROID_QT6 = "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 == "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 to_url(self) -> str:
          return "online/qtsdkrepository/{os}{arch}/{target}/".format(
              os=self.host,
              arch="_x86" if self.host == "windows" else "_x64",
              target=self.target,
          )
  
      def to_folder(self, qt_version_no_dots: str) -> str:
          return "{category}{major}_{ver}{ext}".format(
              category=self.category,
              major=qt_version_no_dots[0],
              ver=qt_version_no_dots,
              ext="_" + self.extension if self.extension else "",
          )
  
      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 ToolData:
      """A data class hold tool details."""
  
      head = [
          "Tool Variant Name",
          "Version",
          "Release Date",
          "Display Name",
          "Description",
      ]
  
      short_head = [
          "Tool Variant Name",
          "Version",
          "Release Date",
      ]
  
      def __init__(self, tool_data: Dict[str, Dict[str, str]]):
          self.tool_data: Dict[str, Dict[str, str]] = tool_data
          for key in tool_data.keys():
              self.tool_data[key]["Description"] = tool_data[key]["Description"].replace("<br>", "\n")
  
      def __format__(self, format_spec: str) -> str:
          short = False
          if format_spec == "{:s}":
              return str(self)
          if format_spec == "":
              max_width: int = 0
          elif format_spec == "{:T}":
              short = True
              max_width = 0
          else:
              match = re.match(r"\{?:?(\d+)t\}?", format_spec)
              if match:
                  g = match.groups()
                  max_width = int(g[0])
              else:
                  raise ValueError("Wrong format {}".format(format_spec))
          table = Texttable(max_width=max_width)
          table.set_deco(Texttable.HEADER)
          if short:
              table.header(self.short_head)
              table.add_rows(self._short_rows(), header=False)
          else:
              table.header(self.head)
              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()]
  
      def _short_rows(self):
          keys = ("Version", "ReleaseDate")
          return [[name, *[content[key] for key in keys]] for name, content in self.tool_data.items()]
  
  
  class QtRepoProperty:
      """
      Describes properties of the Qt repository at https://download.qt.io/online/qtsdkrepository.
      Intended to help decouple the logic of aqt from specific properties of the Qt repository.
      """
  
      @staticmethod
      def extension_for_arch(architecture: str, is_version_ge_6: bool) -> str:
          if architecture == "wasm_32":
              return "wasm"
          elif architecture.startswith("android_") and is_version_ge_6:
              ext = architecture[len("android_") :]
              if ext in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6:
                  return ext
          return ""
  
      @staticmethod
      def possible_extensions_for_arch(arch: str) -> List[str]:
          """Assumes no knowledge of the Qt version"""
  
          # ext_ge_6: the extension if the version is greater than or equal to 6.0.0
          # ext_lt_6: the extension if the version is less than 6.0.0
          ext_lt_6, ext_ge_6 = [QtRepoProperty.extension_for_arch(arch, is_ge_6) for is_ge_6 in (False, True)]
          if ext_lt_6 == ext_ge_6:
              return [ext_lt_6]
          return [ext_lt_6, ext_ge_6]
  
  
  class MetadataFactory:
      """Retrieve metadata of Qt variations, versions, and descriptions from Qt site."""
  
      def __init__(
          self,
          archive_id: ArchiveId,
          *,
          spec: Optional[SimpleSpec] = None,
          is_latest_version: bool = False,
          modules_query: Optional[Tuple[str, str]] = None,
          extensions_ver: Optional[str] = None,
          architectures_ver: Optional[str] = None,
          archives_query: Optional[List[str]] = None,
          src_doc_examples_query: Optional[Tuple[str, Version, bool]] = None,
          tool_name: Optional[str] = None,
          is_long_listing: bool = False,
      ):
          """
          Construct MetadataFactory.
  
          :param spec:                When set, the MetadataFactory will filter out all versions of
                                      Qt that don't fit this SimpleSpec.
          :param is_latest_version:   When True, the MetadataFactory will find all versions of Qt
                                      matching filters, and only print the most recent version
          :param modules_query:       [Version of Qt, architecture] 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
          :param archives_query:      [Qt_Version, architecture, *module_names]: used to print list of archives
          :param tool_name:           Name of a tool, without architecture, ie "tools_qtcreator" or "tools_ifw"
          :param is_long_listing:     If true, long listing is used for tools output
          """
          self.logger = getLogger("aqt.metadata")
          self.archive_id = archive_id
          self.spec = spec
  
          if archive_id.is_tools():
              if tool_name:
                  if not tool_name.startswith("tools_"):
                      tool_name = "tools_" + tool_name
                  if is_long_listing:
                      self.request_type = "tool long listing"
                      self._action = lambda: self.fetch_tool_long_listing(tool_name)
                  else:
                      self.request_type = "tool variant names"
                      self._action = lambda: self.fetch_tool_modules(tool_name)
              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_query:
              self.request_type = "modules"
              version, arch = modules_query
              self._action = lambda: self.fetch_modules(self._to_version(version), arch)
          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))
          elif archives_query:
              if len(archives_query) < 2:
                  raise CliInputError("The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter.")
              self.request_type = "archives for modules" if len(archives_query) > 2 else "archives for qt"
              version, arch, modules = archives_query[0], archives_query[1], archives_query[2:]
              self._action = lambda: self.fetch_archives(self._to_version(version), arch, modules)
          elif src_doc_examples_query:
              cmd_type, version, is_modules_query = src_doc_examples_query
              if is_modules_query:
                  self.request_type = f"modules for {cmd_type}"
                  self._action = lambda: self.fetch_modules_sde(cmd_type, version)
              else:
                  self.request_type = f"archives for {cmd_type}"
                  self._action = lambda: self.fetch_archives_sde(cmd_type, version)
          else:
              self.request_type = "versions"
              self._action = self.fetch_versions
  
      def getList(self) -> Union[List[str], Versions, ToolData]:
          return self._action()
  
      def fetch_arches(self, version: Version) -> List[str]:
          self.validate_extension(version)
          if self.archive_id.extension == "src_doc_examples":
              return []
          qt_ver_str = self._get_qt_version_str(version)
          modules = self._fetch_module_metadata(self.archive_id.to_folder(qt_ver_str))
  
          arches = []
          for name in modules.keys():
              ver, arch = name.split(".")[-2:]
              if ver == qt_ver_str:
                  arches.append(arch)
  
          return arches
  
      def fetch_extensions(self, version: Version) -> List[str]:
          versions_extensions = MetadataFactory.get_versions_extensions(
              self.fetch_http(self.archive_id.to_url(), False), 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.spec is None or version in self.spec) and (self.archive_id.extension == extension)
  
          def get_version(ver_ext: Tuple[Version, str]):
              return ver_ext[0]
  
          versions_extensions = MetadataFactory.get_versions_extensions(
              self.fetch_http(self.archive_id.to_url(), False), self.archive_id.category
          )
          versions = sorted(filter(None, map(get_version, filter(filter_by, versions_extensions))))
          iterables = itertools.groupby(versions, lambda version: version.minor)
          return Versions(iterables)
  
      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(), False)
          return list(MetadataFactory.iterate_folders(html_doc, "tools"))
  
      def fetch_tool_modules(self, tool_name: str) -> List[str]:
          tool_data = self._fetch_module_metadata(tool_name)
          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_module_metadata(tool_name)
          return self.choose_highest_version_in_spec(all_tools_data, simple_spec)
  
      def fetch_tool_long_listing(self, tool_name: str) -> ToolData:
          return ToolData(self._fetch_module_metadata(tool_name))
  
      def validate_extension(self, qt_ver: Version) -> None:
          """
          Checks extension, and raises CliInputError if invalid.
  
          Rules:
          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+
          """
          if (
              self.archive_id.target == "android"
              and qt_ver.major == 6
              and self.archive_id.extension not in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6
          ):
              raise CliInputError(
                  "Qt 6 for Android requires one of the following extensions: "
                  f"{ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6}. "
                  "Please add your extension using the `--extension` flag."
              )
          if self.archive_id.extension in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6 and (
              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):
              raise CliInputError(
                  f"The extension '{self.archive_id.extension}' is only available in Qt 5.13-5.15 and 6.2+ on desktop."
              )
  
      @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
  
  [... the rest is omitted ...]
files: aqt/metadata.py
files: #!/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 binascii
  import itertools
  import operator
  import posixpath
  import random
  import re
  import shutil
  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, ArchiveListError, CliInputError, EmptyMetadata
  from aqt.helper import Settings, get_hash, getUrl, xml_to_modules
  
  
  class SimpleSpec(SemanticSimpleSpec):
      pass
  
      @staticmethod
      def usage() -> str:
          return (
              "See documentation at: "
              "https://python-semanticversion.readthedocs.io/en/latest/reference.html#semantic_version.SimpleSpec\n"
              "Examples:\n"
              '* "*": matches everything\n'
              '* "5": matches every version with major=5\n'
              '* "5.6": matches every version beginning with 5.6\n'
              '* "5.*.3": matches versions with major=5 and patch=3'
          )
  
  
  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) -> Generator[List[Version], None, None]:
          for item in self.versions:
              yield item
  
      def flattened(self) -> List[Version]:
          """Return a flattened list of all versions"""
          return [version for row in self for version in row]
  
  
  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", "qt")
      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",
      )
      EXTENSIONS_REQUIRED_ANDROID_QT6 = "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 == "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 to_url(self) -> str:
          return "online/qtsdkrepository/{os}{arch}/{target}/".format(
              os=self.host,
              arch="_x86" if self.host == "windows" else "_x64",
              target=self.target,
          )
  
      def to_folder(self, qt_version_no_dots: str) -> str:
          return "{category}{major}_{ver}{ext}".format(
              category=self.category,
              major=qt_version_no_dots[0],
              ver=qt_version_no_dots,
              ext="_" + self.extension if self.extension else "",
          )
  
      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 ToolData:
      """A data class hold tool details."""
  
      head = [
          "Tool Variant Name",
          "Version",
          "Release Date",
          "Display Name",
          "Description",
      ]
  
      short_head = [
          "Tool Variant Name",
          "Version",
          "Release Date",
      ]
  
      def __init__(self, tool_data: Dict[str, Dict[str, str]]):
          self.tool_data: Dict[str, Dict[str, str]] = tool_data
          for key in tool_data.keys():
              self.tool_data[key]["Description"] = tool_data[key]["Description"].replace("<br>", "\n")
  
      def __format__(self, format_spec: str) -> str:
          short = False
          if format_spec == "{:s}":
              return str(self)
          if format_spec == "":
              max_width: int = 0
          elif format_spec == "{:T}":
              short = True
              max_width = 0
          else:
              match = re.match(r"\{?:?(\d+)t\}?", format_spec)
              if match:
                  g = match.groups()
                  max_width = int(g[0])
              else:
                  raise ValueError("Wrong format {}".format(format_spec))
          table = Texttable(max_width=max_width)
          table.set_deco(Texttable.HEADER)
          if short:
              table.header(self.short_head)
              table.add_rows(self._short_rows(), header=False)
          else:
              table.header(self.head)
              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()]
  
      def _short_rows(self):
          keys = ("Version", "ReleaseDate")
          return [[name, *[content[key] for key in keys]] for name, content in self.tool_data.items()]
  
  
  class QtRepoProperty:
      """
      Describes properties of the Qt repository at https://download.qt.io/online/qtsdkrepository.
      Intended to help decouple the logic of aqt from specific properties of the Qt repository.
      """
  
      @staticmethod
      def extension_for_arch(architecture: str, is_version_ge_6: bool) -> str:
          if architecture == "wasm_32":
              return "wasm"
          elif architecture.startswith("android_") and is_version_ge_6:
              ext = architecture[len("android_") :]
              if ext in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6:
                  return ext
          return ""
  
      @staticmethod
      def possible_extensions_for_arch(arch: str) -> List[str]:
          """Assumes no knowledge of the Qt version"""
  
          # ext_ge_6: the extension if the version is greater than or equal to 6.0.0
          # ext_lt_6: the extension if the version is less than 6.0.0
          ext_lt_6, ext_ge_6 = [QtRepoProperty.extension_for_arch(arch, is_ge_6) for is_ge_6 in (False, True)]
          if ext_lt_6 == ext_ge_6:
              return [ext_lt_6]
          return [ext_lt_6, ext_ge_6]
  
  
  class MetadataFactory:
      """Retrieve metadata of Qt variations, versions, and descriptions from Qt site."""
  
      def __init__(
          self,
          archive_id: ArchiveId,
          *,
          spec: Optional[SimpleSpec] = None,
          is_latest_version: bool = False,
          modules_query: Optional[Tuple[str, str]] = None,
          extensions_ver: Optional[str] = None,
          architectures_ver: Optional[str] = None,
          archives_query: Optional[List[str]] = None,
          src_doc_examples_query: Optional[Tuple[str, Version, bool]] = None,
          tool_name: Optional[str] = None,
          is_long_listing: bool = False,
      ):
          """
          Construct MetadataFactory.
  
          :param spec:                When set, the MetadataFactory will filter out all versions of
                                      Qt that don't fit this SimpleSpec.
          :param is_latest_version:   When True, the MetadataFactory will find all versions of Qt
                                      matching filters, and only print the most recent version
          :param modules_query:       [Version of Qt, architecture] 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
          :param archives_query:      [Qt_Version, architecture, *module_names]: used to print list of archives
          :param tool_name:           Name of a tool, without architecture, ie "tools_qtcreator" or "tools_ifw"
          :param is_long_listing:     If true, long listing is used for tools output
          """
          self.logger = getLogger("aqt.metadata")
          self.archive_id = archive_id
          self.spec = spec
  
          if archive_id.is_tools():
              if tool_name:
                  if not tool_name.startswith("tools_"):
                      tool_name = "tools_" + tool_name
                  if is_long_listing:
                      self.request_type = "tool long listing"
                      self._action = lambda: self.fetch_tool_long_listing(tool_name)
                  else:
                      self.request_type = "tool variant names"
                      self._action = lambda: self.fetch_tool_modules(tool_name)
              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_query:
              self.request_type = "modules"
              version, arch = modules_query
              self._action = lambda: self.fetch_modules(self._to_version(version), arch)
          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))
          elif archives_query:
              if len(archives_query) < 2:
                  raise CliInputError("The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter.")
              self.request_type = "archives for modules" if len(archives_query) > 2 else "archives for qt"
              version, arch, modules = archives_query[0], archives_query[1], archives_query[2:]
              self._action = lambda: self.fetch_archives(self._to_version(version), arch, modules)
          elif src_doc_examples_query:
              cmd_type, version, is_modules_query = src_doc_examples_query
              if is_modules_query:
                  self.request_type = f"modules for {cmd_type}"
                  self._action = lambda: self.fetch_modules_sde(cmd_type, version)
              else:
                  self.request_type = f"archives for {cmd_type}"
                  self._action = lambda: self.fetch_archives_sde(cmd_type, version)
          else:
              self.request_type = "versions"
              self._action = self.fetch_versions
  
      def getList(self) -> Union[List[str], Versions, ToolData]:
          return self._action()
  
      def fetch_arches(self, version: Version) -> List[str]:
          self.validate_extension(version)
          if self.archive_id.extension == "src_doc_examples":
              return []
          qt_ver_str = self._get_qt_version_str(version)
          modules = self._fetch_module_metadata(self.archive_id.to_folder(qt_ver_str))
  
          arches = []
          for name in modules.keys():
              ver, arch = name.split(".")[-2:]
              if ver == qt_ver_str:
                  arches.append(arch)
  
          return arches
  
      def fetch_extensions(self, version: Version) -> List[str]:
          versions_extensions = MetadataFactory.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.spec is None or version in self.spec) and (self.archive_id.extension == extension)
  
          def get_version(ver_ext: Tuple[Version, str]):
              return ver_ext[0]
  
          versions_extensions = MetadataFactory.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(MetadataFactory.iterate_folders(html_doc, "tools"))
  
      def fetch_tool_modules(self, tool_name: str) -> List[str]:
          tool_data = self._fetch_module_metadata(tool_name)
          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_module_metadata(tool_name)
          return self.choose_highest_version_in_spec(all_tools_data, simple_spec)
  
      def fetch_tool_long_listing(self, tool_name: str) -> ToolData:
          return ToolData(self._fetch_module_metadata(tool_name))
  
      def validate_extension(self, qt_ver: Version) -> None:
          """
          Checks extension, and raises CliInputError if invalid.
  
          Rules:
          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+
          """
          if (
              self.archive_id.target == "android"
              and qt_ver.major == 6
              and self.archive_id.extension not in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6
          ):
              raise CliInputError(
                  "Qt 6 for Android requires one of the following extensions: "
                  f"{ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6}. "
                  "Please add your extension using the `--extension` flag."
              )
          if self.archive_id.extension in ArchiveId.EXTENSIONS_REQUIRED_ANDROID_QT6 and (
              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):
              raise CliInputError(
                  f"The extension '{self.archive_id.extension}' is only available in Qt 5.13-5.15 and 6.2+ on desktop."
              )
  
      @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
  
  [... the rest is omitted ...]
files: aqt/metadata.py
files: import re
  import sys
  from pathlib import Path
  from typing import Optional
  
  import pytest
  
  from aqt.exceptions import CliInputError
  from aqt.installer import Cli
  from aqt.metadata import MetadataFactory, SimpleSpec, Version
  
  
  def expected_help(actual, prefix=None):
      expected = (
          "usage: aqt [-h] [-c CONFIG]\n"
          "           {install-qt,install-tool,install-doc,install-example,install-src,"
          "list-qt,list-tool,list-doc,list-example,list-src,"
          "install,tool,doc,examples,src,help,version}\n"
          "           ...\n"
          "\n"
          "Another unofficial Qt Installer.\n"
          "aqt helps you install Qt SDK, tools, examples and others\n"
          "\n"
          "option",
          "  -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,list-doc,list-example,list-src,"
          "install,tool,doc,examples,src,help,version}\n"
          "                        Please refer to each help message by using '--help' "
          "with each subcommand\n",
      )
      if prefix is not None:
          return actual.startswith(prefix + expected[0]) and actual.endswith(expected[1])
      return actual.startswith(expected[0]) and actual.endswith(expected[1])
  
  
  def test_cli_help(capsys):
      cli = Cli()
      cli.run(["help"])
      out, err = capsys.readouterr()
      assert expected_help(out)
  
  
  def test_cli_check_module():
      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"])
      assert cli._check_modules_arg("5.14.0", None)
      assert not cli._check_modules_arg("5.15.0", ["Unknown"])
  
  
  def test_cli_check_combination():
      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")
      assert not cli._check_qt_arg_combination("5.14.0", "android", "desktop", "clang_64")
  
  
  def test_cli_check_version():
      cli = Cli()
      cli._setup_settings()
      assert cli._check_qt_arg_versions("5.12.0")
      assert not cli._check_qt_arg_versions("5.12")
  
  
  @pytest.mark.parametrize(
      "host, target, arch, version_or_spec, expected_version, is_bad_spec",
      (
          ("windows", "desktop", "wasm_32", "6.1", None, False),
          ("windows", "desktop", "wasm_32", "5.12", None, False),
          ("windows", "desktop", "wasm_32", "5.13", Version("5.13.2"), False),
          ("windows", "desktop", "wasm_32", "5", Version("5.15.2"), False),
          ("windows", "desktop", "wasm_32", "<5.14.5", Version("5.14.2"), False),
          ("windows", "desktop", "mingw32", "6.0", Version("6.0.3"), False),
          ("windows", "winrt", "mingw32", "6", None, False),
          ("windows", "winrt", "mingw32", "bad spec", None, True),
          ("windows", "android", "android_x86", "6", Version("6.1.0"), False),
          ("windows", "desktop", "android_x86", "6", Version("6.1.0"), False),  # does not validate arch
          ("windows", "desktop", "android_fake", "6", Version("6.1.0"), False),  # does not validate arch
      ),
  )
  def test_cli_determine_qt_version(
      monkeypatch, host, target, arch, version_or_spec: str, expected_version: Version, is_bad_spec: bool
  ):
      _html = (Path(__file__).parent / "data" / f"{host}-{target}.html").read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda *args, **kwargs: _html)
      cli = Cli()
      cli._setup_settings()
  
      if is_bad_spec:
          with pytest.raises(CliInputError) as e:
              Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert e.type == CliInputError
          assert format(e.value) == f"Invalid version or SimpleSpec: '{version_or_spec}'\n" + SimpleSpec.usage()
      elif not expected_version:
          with pytest.raises(CliInputError) as e:
              Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert e.type == CliInputError
          expect_msg = f"No versions of Qt exist for spec={version_or_spec} with host={host}, target={target}, arch={arch}"
          actual_msg = format(e.value)
          assert actual_msg == expect_msg
      else:
          ver = Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert ver == expected_version
  
  
  @pytest.mark.parametrize(
      "invalid_version",
      ("5.15", "five-dot-fifteen", "5", "5.5.5.5"),
  )
  def test_cli_invalid_version(capsys, invalid_version):
      """Checks that invalid version strings are handled properly"""
  
      # Ensure that invalid_version cannot be a semantic_version.Version
      with pytest.raises(ValueError):
          Version(invalid_version)
  
      cli = Cli()
      cli._setup_settings()
  
      matcher = re.compile(
          r"^aqtinstall\(aqt\) v.* on Python 3.*\n"
          r"(.*\n)*"
          r".*Invalid version: '" + invalid_version + r"'! Please use the form '5\.X\.Y'\.\n.*"
      )
  
      for cmd in (
          ("install", invalid_version, "mac", "desktop"),
          ("doc", invalid_version, "mac", "desktop"),
          ("list-qt", "mac", "desktop", "--arch", invalid_version),
      ):
          cli = Cli()
          assert cli.run(cmd) == 1
          out, err = capsys.readouterr()
          sys.stdout.write(out)
          sys.stderr.write(err)
          assert matcher.match(err)
  
  
  @pytest.mark.parametrize(
      "version, allow_latest, allow_empty, allow_minus, expect_ok",
      (
          ("1.2.3", False, False, False, True),
          ("1.2.", False, False, False, False),
          ("latest", True, False, False, True),
          ("latest", False, False, False, False),
          ("", False, True, False, True),
          ("", False, False, False, False),
          ("1.2.3-0-123", False, False, True, True),
          ("1.2.3-0-123", False, False, False, False),
      ),
  )
  def test_cli_validate_version(version: str, allow_latest: bool, allow_empty: bool, allow_minus: bool, expect_ok: bool):
      if expect_ok:
          Cli._validate_version_str(version, allow_latest=allow_latest, allow_empty=allow_empty, allow_minus=allow_minus)
      else:
          with pytest.raises(CliInputError) as err:
              Cli._validate_version_str(version, allow_latest=allow_latest, allow_empty=allow_empty, allow_minus=allow_minus)
          assert err.type == CliInputError
  
  
  def test_cli_check_mirror():
      cli = Cli()
      cli._setup_settings()
      assert cli._check_mirror(None)
      arg = ["install-qt", "linux", "desktop", "5.11.3", "-b", "https://download.qt.io/"]
      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",
      (
          ("impossible_arch", "windows", "desktop", "6.2.0", "impossible_arch"),
          ("", "windows", "desktop", "6.2.0", None),
          (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),
          # SimpleSpec instead of Version
          ("impossible_arch", "windows", "desktop", "6.2", "impossible_arch"),
          ("", "windows", "desktop", "6.2", None),
          (None, "windows", "desktop", "6.2", None),
          (None, "linux", "desktop", "6.2", "gcc_64"),
          (None, "mac", "desktop", "6.2", "clang_64"),
          (None, "mac", "ios", "6.2", "ios"),
          (None, "mac", "android", "6.2", None),  # No way to determine arch for android target w/o version
      ),
  )
  def test_set_arch(arch: Optional[str], host: str, target: str, version: str, expect: Optional[str]):
  
      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, cmd, expect_msg, should_show_help):
      cli = Cli()
      cli._setup_settings()
      assert 1 == cli.run(cmd.split())
      out, err = capsys.readouterr()
      if should_show_help:
          assert expected_help(out)
      else:
          assert out == ""
      assert err.rstrip().endswith(expect_msg)
  
  
  # These commands use the new syntax with the legacy commands
  @pytest.mark.parametrize(
      "cmd",
      (
          "install linux desktop 5.10.0",
          "install linux desktop 5.10.0 gcc_64",
          "src linux desktop 5.10.0",
          "doc linux desktop 5.10.0",
          "example linux desktop 5.10.0",
          "tool windows desktop tools_ifw",
      ),
  )
  def test_cli_legacy_commands_with_wrong_syntax(cmd):
      cli = Cli()
      cli._setup_settings()
      with pytest.raises(SystemExit) as e:
          cli.run(cmd.split())
      assert e.type == SystemExit
  
  
  @pytest.mark.parametrize(
      "cmd",
      (
          "tool windows desktop tools_ifw qt.tools.ifw.31",  # New syntax
          "tool windows desktop tools_ifw 1.2.3",
      ),
  )
  def test_cli_legacy_tool_new_syntax(monkeypatch, capsys, cmd):
      # These incorrect commands cannot be filtered out directly by argparse because
      # they have the correct number of arguments.
      command = cmd.split()
  
      expected = (
          "Warning: The command 'tool' is deprecated and marked for removal in a future version of aqt.\n"
          "In the future, please use the command 'install-tool' instead.\n"
          "Invalid version: 'tools_ifw'! Please use the form '5.X.Y'.\n"
      )
  
      cli = Cli()
      cli._setup_settings()
      assert 1 == cli.run(command)
      out, err = capsys.readouterr()
      actual = err[err.index("\n") + 1 :]
      assert actual == expected
  
  
  # These commands come directly from examples in the legacy documentation
  @pytest.mark.parametrize(
      "cmd",
      (
          "install 5.10.0 linux desktop",  # default arch
          "install 5.10.2 linux android android_armv7",
          "src 5.15.2 windows desktop --archives qtbase --kde",
          "doc 5.15.2 windows desktop -m qtcharts qtnetworkauth",
          "examples 5.15.2 windows desktop -m qtcharts qtnetworkauth",
          "tool linux tools_ifw 4.0 qt.tools.ifw.40",
      ),
  )
  def test_cli_legacy_commands_with_correct_syntax(monkeypatch, cmd):
      # Pretend to install correctly when any command is run
      for func in ("run_install_qt", "run_install_src", "run_install_doc", "run_install_example", "run_install_tool"):
          monkeypatch.setattr(Cli, func, lambda *args, **kwargs: 0)
  
      cli = Cli()
      cli._setup_settings()
      assert 0 == cli.run(cmd.split())
  
  
  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 Cli.UNHANDLED_EXCEPTION_CODE == 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'"
  
  
  @pytest.mark.parametrize(
      "archive_dest, keep, temp_dir, expect, should_make_dir",
      (
          (None, False, "temp", "temp", False),
          (None, True, "temp", ".", False),
          ("my_archives", False, "temp", "my_archives", True),
          ("my_archives", True, "temp", "my_archives", True),
      ),
  )
  def test_cli_choose_archive_dest(
      monkeypatch, archive_dest: Optional[str], keep: bool, temp_dir: str, expect: str, should_make_dir: bool
  ):
      enclosed = {"made_dir": False}
  
      def mock_mkdir(*args, **kwargs):
          enclosed["made_dir"] = True
  
      monkeypatch.setattr("aqt.installer.Path.mkdir", mock_mkdir)
  
      assert Cli.choose_archive_dest(archive_dest, keep, temp_dir) == Path(expect)
      assert enclosed["made_dir"] == should_make_dir
  
  
  [... the rest is omitted ...]
files: tests/test_cli.py
files: import re
  import sys
  from pathlib import Path
  from typing import Optional
  
  import pytest
  
  from aqt.exceptions import CliInputError
  from aqt.installer import Cli
  from aqt.metadata import MetadataFactory, SimpleSpec, Version
  
  
  def expected_help(actual, prefix=None):
      expected = (
          "usage: aqt [-h] [-c CONFIG]\n"
          "           {install-qt,install-tool,install-doc,install-example,install-src,"
          "list-qt,list-tool,list-doc,list-example,list-src,"
          "install,tool,doc,examples,src,help,version}\n"
          "           ...\n"
          "\n"
          "Another unofficial Qt Installer.\n"
          "aqt helps you install Qt SDK, tools, examples and others\n"
          "\n"
          "option",
          "  -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,list-doc,list-example,list-src,"
          "install,tool,doc,examples,src,help,version}\n"
          "                        Please refer to each help message by using '--help' "
          "with each subcommand\n",
      )
      if prefix is not None:
          return actual.startswith(prefix + expected[0]) and actual.endswith(expected[1])
      return actual.startswith(expected[0]) and actual.endswith(expected[1])
  
  
  def test_cli_help(capsys):
      cli = Cli()
      cli.run(["help"])
      out, err = capsys.readouterr()
      assert expected_help(out)
  
  
  def test_cli_check_module():
      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"])
      assert cli._check_modules_arg("5.14.0", None)
      assert not cli._check_modules_arg("5.15.0", ["Unknown"])
  
  
  def test_cli_check_combination():
      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")
      assert not cli._check_qt_arg_combination("5.14.0", "android", "desktop", "clang_64")
  
  
  def test_cli_check_version():
      cli = Cli()
      cli._setup_settings()
      assert cli._check_qt_arg_versions("5.12.0")
      assert not cli._check_qt_arg_versions("5.12")
  
  
  @pytest.mark.parametrize(
      "host, target, arch, version_or_spec, expected_version, is_bad_spec",
      (
          ("windows", "desktop", "wasm_32", "6.1", None, False),
          ("windows", "desktop", "wasm_32", "5.12", None, False),
          ("windows", "desktop", "wasm_32", "5.13", Version("5.13.2"), False),
          ("windows", "desktop", "wasm_32", "5", Version("5.15.2"), False),
          ("windows", "desktop", "wasm_32", "<5.14.5", Version("5.14.2"), False),
          ("windows", "desktop", "mingw32", "6.0", Version("6.0.3"), False),
          ("windows", "winrt", "mingw32", "6", None, False),
          ("windows", "winrt", "mingw32", "bad spec", None, True),
          ("windows", "android", "android_x86", "6", Version("6.1.0"), False),
          ("windows", "desktop", "android_x86", "6", Version("6.1.0"), False),  # does not validate arch
          ("windows", "desktop", "android_fake", "6", Version("6.1.0"), False),  # does not validate arch
      ),
  )
  def test_cli_determine_qt_version(
      monkeypatch, host, target, arch, version_or_spec: str, expected_version: Version, is_bad_spec: bool
  ):
      _html = (Path(__file__).parent / "data" / f"{host}-{target}.html").read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _html)
      cli = Cli()
      cli._setup_settings()
  
      if is_bad_spec:
          with pytest.raises(CliInputError) as e:
              Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert e.type == CliInputError
          assert format(e.value) == f"Invalid version or SimpleSpec: '{version_or_spec}'\n" + SimpleSpec.usage()
      elif not expected_version:
          with pytest.raises(CliInputError) as e:
              Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert e.type == CliInputError
          expect_msg = f"No versions of Qt exist for spec={version_or_spec} with host={host}, target={target}, arch={arch}"
          actual_msg = format(e.value)
          assert actual_msg == expect_msg
      else:
          ver = Cli._determine_qt_version(version_or_spec, host, target, arch)
          assert ver == expected_version
  
  
  @pytest.mark.parametrize(
      "invalid_version",
      ("5.15", "five-dot-fifteen", "5", "5.5.5.5"),
  )
  def test_cli_invalid_version(capsys, invalid_version):
      """Checks that invalid version strings are handled properly"""
  
      # Ensure that invalid_version cannot be a semantic_version.Version
      with pytest.raises(ValueError):
          Version(invalid_version)
  
      cli = Cli()
      cli._setup_settings()
  
      matcher = re.compile(
          r"^aqtinstall\(aqt\) v.* on Python 3.*\n"
          r"(.*\n)*"
          r".*Invalid version: '" + invalid_version + r"'! Please use the form '5\.X\.Y'\.\n.*"
      )
  
      for cmd in (
          ("install", invalid_version, "mac", "desktop"),
          ("doc", invalid_version, "mac", "desktop"),
          ("list-qt", "mac", "desktop", "--arch", invalid_version),
      ):
          cli = Cli()
          assert cli.run(cmd) == 1
          out, err = capsys.readouterr()
          sys.stdout.write(out)
          sys.stderr.write(err)
          assert matcher.match(err)
  
  
  @pytest.mark.parametrize(
      "version, allow_latest, allow_empty, allow_minus, expect_ok",
      (
          ("1.2.3", False, False, False, True),
          ("1.2.", False, False, False, False),
          ("latest", True, False, False, True),
          ("latest", False, False, False, False),
          ("", False, True, False, True),
          ("", False, False, False, False),
          ("1.2.3-0-123", False, False, True, True),
          ("1.2.3-0-123", False, False, False, False),
      ),
  )
  def test_cli_validate_version(version: str, allow_latest: bool, allow_empty: bool, allow_minus: bool, expect_ok: bool):
      if expect_ok:
          Cli._validate_version_str(version, allow_latest=allow_latest, allow_empty=allow_empty, allow_minus=allow_minus)
      else:
          with pytest.raises(CliInputError) as err:
              Cli._validate_version_str(version, allow_latest=allow_latest, allow_empty=allow_empty, allow_minus=allow_minus)
          assert err.type == CliInputError
  
  
  def test_cli_check_mirror():
      cli = Cli()
      cli._setup_settings()
      assert cli._check_mirror(None)
      arg = ["install-qt", "linux", "desktop", "5.11.3", "-b", "https://download.qt.io/"]
      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",
      (
          ("impossible_arch", "windows", "desktop", "6.2.0", "impossible_arch"),
          ("", "windows", "desktop", "6.2.0", None),
          (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),
          # SimpleSpec instead of Version
          ("impossible_arch", "windows", "desktop", "6.2", "impossible_arch"),
          ("", "windows", "desktop", "6.2", None),
          (None, "windows", "desktop", "6.2", None),
          (None, "linux", "desktop", "6.2", "gcc_64"),
          (None, "mac", "desktop", "6.2", "clang_64"),
          (None, "mac", "ios", "6.2", "ios"),
          (None, "mac", "android", "6.2", None),  # No way to determine arch for android target w/o version
      ),
  )
  def test_set_arch(arch: Optional[str], host: str, target: str, version: str, expect: Optional[str]):
  
      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, cmd, expect_msg, should_show_help):
      cli = Cli()
      cli._setup_settings()
      assert 1 == cli.run(cmd.split())
      out, err = capsys.readouterr()
      if should_show_help:
          assert expected_help(out)
      else:
          assert out == ""
      assert err.rstrip().endswith(expect_msg)
  
  
  # These commands use the new syntax with the legacy commands
  @pytest.mark.parametrize(
      "cmd",
      (
          "install linux desktop 5.10.0",
          "install linux desktop 5.10.0 gcc_64",
          "src linux desktop 5.10.0",
          "doc linux desktop 5.10.0",
          "example linux desktop 5.10.0",
          "tool windows desktop tools_ifw",
      ),
  )
  def test_cli_legacy_commands_with_wrong_syntax(cmd):
      cli = Cli()
      cli._setup_settings()
      with pytest.raises(SystemExit) as e:
          cli.run(cmd.split())
      assert e.type == SystemExit
  
  
  @pytest.mark.parametrize(
      "cmd",
      (
          "tool windows desktop tools_ifw qt.tools.ifw.31",  # New syntax
          "tool windows desktop tools_ifw 1.2.3",
      ),
  )
  def test_cli_legacy_tool_new_syntax(monkeypatch, capsys, cmd):
      # These incorrect commands cannot be filtered out directly by argparse because
      # they have the correct number of arguments.
      command = cmd.split()
  
      expected = (
          "Warning: The command 'tool' is deprecated and marked for removal in a future version of aqt.\n"
          "In the future, please use the command 'install-tool' instead.\n"
          "Invalid version: 'tools_ifw'! Please use the form '5.X.Y'.\n"
      )
  
      cli = Cli()
      cli._setup_settings()
      assert 1 == cli.run(command)
      out, err = capsys.readouterr()
      actual = err[err.index("\n") + 1 :]
      assert actual == expected
  
  
  # These commands come directly from examples in the legacy documentation
  @pytest.mark.parametrize(
      "cmd",
      (
          "install 5.10.0 linux desktop",  # default arch
          "install 5.10.2 linux android android_armv7",
          "src 5.15.2 windows desktop --archives qtbase --kde",
          "doc 5.15.2 windows desktop -m qtcharts qtnetworkauth",
          "examples 5.15.2 windows desktop -m qtcharts qtnetworkauth",
          "tool linux tools_ifw 4.0 qt.tools.ifw.40",
      ),
  )
  def test_cli_legacy_commands_with_correct_syntax(monkeypatch, cmd):
      # Pretend to install correctly when any command is run
      for func in ("run_install_qt", "run_install_src", "run_install_doc", "run_install_example", "run_install_tool"):
          monkeypatch.setattr(Cli, func, lambda *args, **kwargs: 0)
  
      cli = Cli()
      cli._setup_settings()
      assert 0 == cli.run(cmd.split())
  
  
  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 Cli.UNHANDLED_EXCEPTION_CODE == 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'"
  
  
  @pytest.mark.parametrize(
      "archive_dest, keep, temp_dir, expect, should_make_dir",
      (
          (None, False, "temp", "temp", False),
          (None, True, "temp", ".", False),
          ("my_archives", False, "temp", "my_archives", True),
          ("my_archives", True, "temp", "my_archives", True),
      ),
  )
  def test_cli_choose_archive_dest(
      monkeypatch, archive_dest: Optional[str], keep: bool, temp_dir: str, expect: str, should_make_dir: bool
  ):
      enclosed = {"made_dir": False}
  
      def mock_mkdir(*args, **kwargs):
          enclosed["made_dir"] = True
  
      monkeypatch.setattr("aqt.installer.Path.mkdir", mock_mkdir)
  
      assert Cli.choose_archive_dest(archive_dest, keep, temp_dir) == Path(expect)
      assert enclosed["made_dir"] == should_make_dir
  
  
  [... the rest is omitted ...]
files: tests/test_cli.py
files: import hashlib
  import json
  import os
  import re
  import shutil
  import sys
  from pathlib import Path
  from typing import Dict, List, Set, Union
  from urllib.parse import urlparse
  
  import pytest
  
  from aqt.exceptions import (
      AqtException,
      ArchiveConnectionError,
      ArchiveDownloadError,
      ArchiveListError,
      CliInputError,
      EmptyMetadata,
  )
  from aqt.helper import Settings
  from aqt.installer import Cli
  from aqt.metadata import (
      ArchiveId,
      MetadataFactory,
      QtRepoProperty,
      SimpleSpec,
      ToolData,
      Version,
      Versions,
      show_list,
      suggested_follow_up,
  )
  
  Settings.load_settings()
  
  
  @pytest.mark.parametrize(
      "arch, version, expect",
      (
          ("wasm_32", Version("5.13.1"), "wasm"),
          ("mingw", Version("5.13.1"), ""),
          ("android_fake", Version("5.13.1"), ""),
          ("android_x86", Version("5.13.1"), ""),
          ("android_x86", Version("6.13.1"), "x86"),
          ("android_x86", Version("6.0.0"), "x86"),
      ),
  )
  def test_list_extension_for_arch(arch: str, version: Version, expect: str):
      ext = QtRepoProperty.extension_for_arch(arch, version >= Version("6.0.0"))
      assert ext == expect
  
  
  @pytest.mark.parametrize(
      "arch, expect",
      (
          ("wasm_32", ["wasm"]),
          ("mingw", [""]),
          ("android_fake", [""]),
          ("android", [""]),
          ("android_x86", ["", "x86"]),
      ),
  )
  def test_list_possible_extension_for_arch(arch: str, expect: List[str]):
      exts = QtRepoProperty.possible_extensions_for_arch(arch)
      assert set(exts) == set(expect)
  
  
  @pytest.mark.parametrize(
      "init_data, expect_str, expect_fmt, expect_flat, expect_last, expect_bool",
      (
          (
              [
                  (1, [Version("1.1.1"), Version("1.1.2")]),
                  (2, [Version("1.2.1"), Version("1.2.2")]),
              ],
              "[[Version('1.1.1'), Version('1.1.2')], [Version('1.2.1'), Version('1.2.2')]]",
              "1.1.1 1.1.2\n1.2.1 1.2.2",
              [Version("1.1.1"), Version("1.1.2"), Version("1.2.1"), Version("1.2.2")],
              Version("1.2.2"),
              True,
          ),
          (
              [],
              "[]",
              "",
              [],
              None,
              False,
          ),
          (
              Version("1.2.3"),
              "[[Version('1.2.3')]]",
              "1.2.3",
              [Version("1.2.3")],
              Version("1.2.3"),
              True,
          ),
      ),
  )
  def test_versions(init_data, expect_str, expect_fmt, expect_flat, expect_last, expect_bool):
      versions = Versions(init_data)
      assert str(versions) == expect_str
      assert format(versions) == expect_fmt
      assert format(versions, "s") == expect_str
      assert versions.flattened() == expect_flat
      assert versions.latest() == expect_last
      assert bool(versions) == expect_bool
  
      with pytest.raises(TypeError) as pytest_wrapped_e:
          format(versions, "x")
      assert pytest_wrapped_e.type == TypeError
  
  
  @pytest.fixture
  def spec_regex():
      return re.compile(r"^(\d+\.\d+)")
  
  
  @pytest.mark.parametrize(
      "os_name,target,in_file,expect_out_file",
      [
          ("windows", "android", "windows-android.html", "windows-android-expect.json"),
          ("windows", "desktop", "windows-desktop.html", "windows-desktop-expect.json"),
          ("windows", "winrt", "windows-winrt.html", "windows-winrt-expect.json"),
          ("linux", "android", "linux-android.html", "linux-android-expect.json"),
          ("linux", "desktop", "linux-desktop.html", "linux-desktop-expect.json"),
          ("mac", "android", "mac-android.html", "mac-android-expect.json"),
          ("mac", "desktop", "mac-desktop.html", "mac-desktop-expect.json"),
          ("mac", "ios", "mac-ios.html", "mac-ios-expect.json"),
      ],
  )
  def test_list_versions_tools(monkeypatch, spec_regex, os_name, target, in_file, expect_out_file):
      _html = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda *args, **kwargs: _html)
  
      expected = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      # Test 'aqt list-tool'
      tools = MetadataFactory(ArchiveId("tools", os_name, target)).getList()
      assert tools == expected["tools"]
  
      for ext, expected_output in expected["qt"].items():
          # Test 'aqt list-qt'
          archive_id = ArchiveId("qt", os_name, target, ext if ext != "qt" else "")
          all_versions = MetadataFactory(archive_id).getList()
  
          if len(expected_output) == 0:
              assert not all_versions
          else:
              assert f"{all_versions}" == "\n".join(expected_output)
  
          # Filter for the latest version only
          latest_ver = MetadataFactory(archive_id, is_latest_version=True).getList()
  
          if len(expected_output) == 0:
              assert not latest_ver
          else:
              assert f"{latest_ver}" == expected_output[-1].split(" ")[-1]
  
          for row in expected_output:
              spec_str = spec_regex.search(row).group(1)
              spec = SimpleSpec(spec_str) if not ext.endswith("preview") else SimpleSpec(spec_str + ".0-preview")
  
              # Find the latest version for a particular spec
              latest_ver_for_spec = MetadataFactory(
                  archive_id,
                  spec=spec,
                  is_latest_version=True,
              ).getList()
              assert f"{latest_ver_for_spec}" == row.split(" ")[-1]
  
              # Find all versions for a particular spec
              all_ver_for_spec = MetadataFactory(
                  archive_id,
                  spec=spec,
              ).getList()
              assert f"{all_ver_for_spec}" == row
  
  
  @pytest.mark.parametrize(
      "version,extension,in_file,expect_out_file",
      [
          ("5.14.0", "", "windows-5140-update.xml", "windows-5140-expect.json"),
          ("5.15.0", "", "windows-5150-update.xml", "windows-5150-expect.json"),
          (
              "5.15.2",
              "src_doc_examples",
              "windows-5152-src-doc-example-update.xml",
              "windows-5152-src-doc-example-expect.json",
          ),
          ("6.2.0", "", "windows-620-update.xml", "windows-620-expect.json"),
      ],
  )
  def test_list_architectures_and_modules(monkeypatch, version: str, extension: str, in_file: str, expect_out_file: str):
      archive_id = ArchiveId("qt", "windows", "desktop", extension)
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
  
      for arch in expect["architectures"]:
          modules = MetadataFactory(archive_id).fetch_modules(Version(version), arch)
          assert modules == sorted(expect["modules_by_arch"][arch])
  
      arches = MetadataFactory(archive_id).fetch_arches(Version(version))
      assert arches == expect["architectures"]
  
  
  @pytest.fixture
  def win_5152_sde_xml_file() -> str:
      return (Path(__file__).parent / "data" / "windows-5152-src-doc-example-update.xml").read_text("utf-8")
  
  
  def win_5152_sde_expected(cmd_type: str, query_type: str) -> Set[str]:
      assert cmd_type in ("src", "doc", "examples")
      assert query_type in ("archives", "modules")
      _json = json.loads((Path(__file__).parent / "data/windows-5152-src-doc-example-expect.json").read_text("utf-8"))
      return set(_json[cmd_type][query_type])
  
  
  @pytest.mark.parametrize(
      "cmd_type, host, version, expected",
      [
          (
              _cmd_type,
              "windows",
              "5.15.2",
              win_5152_sde_expected(_cmd_type, "archives"),
          )
          for _cmd_type in ("src", "doc", "examples")
      ],
  )
  def test_list_src_doc_examples_archives(
      monkeypatch, win_5152_sde_xml_file, cmd_type: str, host: str, version: str, expected: Set[str]
  ):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      archive_id = ArchiveId("qt", host, "desktop", "src_doc_examples")
      archives = set(MetadataFactory(archive_id).fetch_archives_sde(cmd_type, Version(version)))
      assert archives == expected
  
  
  @pytest.mark.parametrize(
      "cmd_type, host, version, expected",
      [
          (
              _cmd_type,
              "windows",
              "5.15.2",
              win_5152_sde_expected(_cmd_type, "modules"),
          )
          for _cmd_type in ("doc", "examples")
      ],
  )
  def test_list_src_doc_examples_modules(
      monkeypatch, win_5152_sde_xml_file, cmd_type: str, host: str, version: str, expected: Set[str]
  ):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      archive_id = ArchiveId("qt", host, "desktop", "src_doc_examples")
      modules = set(MetadataFactory(archive_id).fetch_modules_sde(cmd_type, Version(version)))
      assert modules == expected
  
  
  @pytest.mark.parametrize(
      "command, expected",
      (
          ("list-src windows 5.15.2", win_5152_sde_expected("src", "archives")),
          ("list-doc windows 5.15.2", win_5152_sde_expected("doc", "archives")),
          ("list-example windows 5.15.2", win_5152_sde_expected("examples", "archives")),
          ("list-doc windows 5.15.2 --modules", win_5152_sde_expected("doc", "modules")),
          ("list-example windows 5.15.2 --modules", win_5152_sde_expected("examples", "modules")),
      ),
  )
  def test_list_src_doc_examples_cli(monkeypatch, capsys, win_5152_sde_xml_file, command: str, expected: Set[str]):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      cli = Cli()
      assert 0 == cli.run(command.split())
      out, err = capsys.readouterr()
      assert not err
      out_set = set(out.strip().split())
      assert out_set == expected
  
  
  @pytest.mark.parametrize(
      "version, arch, modules_to_query, modules_failed_query",
      (
          ("5.14.0", "win32_mingw73", [], []),
          ("5.14.0", "win32_mingw73", ["qtcharts"], []),
          ("5.14.0", "win32_mingw73", ["all"], []),
          ("5.14.0", "win32_mingw73", ["debug_info"], ["debug_info"]),
          ("5.14.0", "win64_msvc2017_64", [], []),
          ("5.14.0", "win64_msvc2017_64", ["debug_info"], []),
      ),
  )
  def test_list_archives(
      monkeypatch, capsys, version: str, arch: str, modules_to_query: List[str], modules_failed_query: List[str]
  ):
      archive_id = ArchiveId("qt", "windows", "desktop")
      in_file = f"{archive_id.host}-{version.replace('.', '')}-update.xml"
      expect_out_file = f"{archive_id.host}-{version.replace('.', '')}-expect.json"
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      if not modules_to_query:
          expected_qt_archives = expect["qt_base_pkgs_by_arch"][arch]["DownloadableArchives"]
          expected = set([arc.split("-")[0] for arc in expected_qt_archives])
      else:
          expected_mod_metadata = expect["modules_metadata_by_arch"][arch]
          if "all" not in modules_to_query:
              expected_mod_metadata = filter(lambda mod: mod["Name"].split(".")[-2] in modules_to_query, expected_mod_metadata)
          expected = set([arc.split("-")[0] for mod in expected_mod_metadata for arc in mod["DownloadableArchives"]])
  
      archives_query = [version, arch, *modules_to_query]
      cli_args = ["list-qt", "windows", "desktop", "--archives", *archives_query]
      if not modules_failed_query:
          meta = set(MetadataFactory(archive_id, archives_query=archives_query).getList())
          assert meta == expected
  
          cli = Cli()
          assert 0 == cli.run(cli_args)
          out, err = capsys.readouterr()
          assert out.rstrip() == " ".join(sorted(expected))
          return
  
      expected_err_msg = f"The requested modules were not located: {sorted(modules_failed_query)}"
      with pytest.raises(CliInputError) as err:
          MetadataFactory(archive_id, archives_query=archives_query).getList()
      assert err.type == CliInputError
      assert format(err.value).startswith(expected_err_msg)
  
      cli = Cli()
      assert 1 == cli.run(cli_args)
      out, err = capsys.readouterr()
      assert expected_err_msg in err
  
  
  def test_list_archives_insufficient_args(capsys):
      cli = Cli()
      assert 1 == cli.run("list-qt mac desktop --archives 5.14.0".split())
      out, err = capsys.readouterr()
      assert err.strip() == "The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter."
  
  
  def test_list_archives_bad_xml(monkeypatch):
      archive_id = ArchiveId("qt", "windows", "desktop")
      archives_query = ["5.15.2", "win32_mingw81", "qtcharts"]
  
      xml_no_name = "<Updates><PackageUpdate><badname></badname></PackageUpdate></Updates>"
      xml_empty_name = "<Updates><PackageUpdate><Name></Name></PackageUpdate></Updates>"
      xml_broken = "<Updates></PackageUpdate><PackageUpdate></Updates><Name></Name>"
  
      for _xml in (xml_no_name, xml_empty_name, xml_broken):
          monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
          with pytest.raises(ArchiveListError) as e:
              MetadataFactory(archive_id, archives_query=archives_query).getList()
          assert e.type == ArchiveListError
  
  
  @pytest.mark.parametrize(
      "host, target, tool_name",
      [
          ("mac", "desktop", "tools_cmake"),
          ("mac", "desktop", "tools_ifw"),
          ("mac", "desktop", "tools_qtcreator"),
      ],
  )
  def test_tool_modules(monkeypatch, host: str, target: str, tool_name: str):
      archive_id = ArchiveId("tools", host, target)
      in_file = "{}-{}-{}-update.xml".format(host, target, tool_name)
      expect_out_file = "{}-{}-{}-expect.json".format(host, target, tool_name)
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
  
      modules = MetadataFactory(archive_id, tool_name=tool_name).getList()
      assert modules == expect["modules"]
  
      table = MetadataFactory(archive_id, tool_name=tool_name, is_long_listing=True).getList()
      assert table._rows() == expect["long_listing"]
  
  
  @pytest.fixture
  def expected_windows_desktop_5140() -> Dict:
      xmlexpect = "windows-5140-expect.json"
      return json.loads((Path(__file__).parent / "data" / xmlexpect).read_text("utf-8"))
  
  
  @pytest.mark.parametrize(
      "args, expect",
      (
          ("--extensions latest", {"src_doc_examples"}),
          ("--spec 5.14 --extensions latest", {"wasm", "src_doc_examples"}),
          ("--extensions 5.14.0", {"wasm", "src_doc_examples"}),
          ("--modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--spec 5.14 --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--modules 5.14.0 win32_mingw73", ["modules_by_arch", "win32_mingw73"]),
          ("--modules 5.14.0 win32_msvc2017", ["modules_by_arch", "win32_msvc2017"]),
          ("--modules 5.14.0 win64_mingw73", ["modules_by_arch", "win64_mingw73"]),
          ("--modules 5.14.0 win64_msvc2015_64", ["modules_by_arch", "win64_msvc2015_64"]),
          ("--modules 5.14.0 win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --spec 5.14 --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --modules 5.14.0 win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--arch latest", ["architectures"]),
          ("--spec 5.14 --arch latest", ["architectures"]),
          ("--arch 5.14.0", ["architectures"]),
          ("--extension wasm --arch latest", ["architectures"]),
          ("--extension wasm --spec 5.14 --arch latest", ["architectures"]),
          ("--extension wasm --arch 5.14.0", ["architectures"]),
      ),
  )
  def test_list_qt_cli(
      monkeypatch,
      capsys,
      expected_windows_desktop_5140: Dict[str, Set[str]],
      args: str,
      expect: Union[Set[str], List[str]],
  ):
      htmlfile, xmlfile = "windows-desktop.html", "windows-5140-update.xml"
      version_string_to_replace = "qt5.5140"
      if isinstance(expect, list):
          # In this case, 'expect' is a list of keys to follow to the expected values.
          expected_dict = expected_windows_desktop_5140
          for key in expect:  # Follow the chain of keys to the list of values.
              expected_dict = expected_dict[key]
          assert isinstance(expected_dict, list)
          expect_set = set(expected_dict)
      else:
          expect_set = expect
      assert isinstance(expect_set, set)
  
      def _mock_fetch_http(_, rest_of_url, *args, **kwargs: str) -> str:
          htmltext = (Path(__file__).parent / "data" / htmlfile).read_text("utf-8")
          if not rest_of_url.endswith("Updates.xml"):
              return htmltext
  
          xmltext = (Path(__file__).parent / "data" / xmlfile).read_text("utf-8")
          # If we are serving an Updates.xml, `aqt list` will look for a Qt version number.
          # We will replace the version numbers in the file with the requested version.
          match = re.search(r"qt(\d)_(\d+)", rest_of_url)
          assert match
          major, version_nodot = match.groups()
          desired_version_string = f"qt{major}.{version_nodot}"
          return xmltext.replace(version_string_to_replace, desired_version_string)
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", _mock_fetch_http)
  
      cli = Cli()
      cli.run(["list-qt", "windows", "desktop", *args.split()])
      out, err = capsys.readouterr()
      output_set = set(out.strip().split())
      assert output_set == expect_set
  
  
  @pytest.mark.parametrize(
      "cmd, host, expect",
      (
          ("list-qt", "windows", {"desktop", "android", "winrt"}),
          ("list-qt", "linux", {"desktop", "android"}),
          ("list-qt", "mac", {"desktop", "android", "ios"}),
          ("list-tool", "windows", {"desktop", "android", "winrt"}),
          ("list-tool", "linux", {"desktop", "android"}),
          ("list-tool", "mac", {"desktop", "android", "ios"}),
      ),
  )
  def test_list_targets(capsys, cmd: str, host: str, expect: Set[str]):
      cli = Cli()
      cli.run([cmd, host])
      out, err = capsys.readouterr()
      output_set = set(out.strip().split())
      assert output_set == expect
  
  
  @pytest.mark.parametrize(
      "
  
  [... the rest is omitted ...]
files: tests/test_list.py
files: import hashlib
  import json
  import os
  import re
  import shutil
  import sys
  from pathlib import Path
  from typing import Dict, List, Set, Union
  from urllib.parse import urlparse
  
  import pytest
  
  from aqt.exceptions import (
      AqtException,
      ArchiveConnectionError,
      ArchiveDownloadError,
      ArchiveListError,
      CliInputError,
      EmptyMetadata,
  )
  from aqt.helper import Settings
  from aqt.installer import Cli
  from aqt.metadata import (
      ArchiveId,
      MetadataFactory,
      QtRepoProperty,
      SimpleSpec,
      ToolData,
      Version,
      Versions,
      show_list,
      suggested_follow_up,
  )
  
  Settings.load_settings()
  
  
  @pytest.mark.parametrize(
      "arch, version, expect",
      (
          ("wasm_32", Version("5.13.1"), "wasm"),
          ("mingw", Version("5.13.1"), ""),
          ("android_fake", Version("5.13.1"), ""),
          ("android_x86", Version("5.13.1"), ""),
          ("android_x86", Version("6.13.1"), "x86"),
          ("android_x86", Version("6.0.0"), "x86"),
      ),
  )
  def test_list_extension_for_arch(arch: str, version: Version, expect: str):
      ext = QtRepoProperty.extension_for_arch(arch, version >= Version("6.0.0"))
      assert ext == expect
  
  
  @pytest.mark.parametrize(
      "arch, expect",
      (
          ("wasm_32", ["wasm"]),
          ("mingw", [""]),
          ("android_fake", [""]),
          ("android", [""]),
          ("android_x86", ["", "x86"]),
      ),
  )
  def test_list_possible_extension_for_arch(arch: str, expect: List[str]):
      exts = QtRepoProperty.possible_extensions_for_arch(arch)
      assert set(exts) == set(expect)
  
  
  @pytest.mark.parametrize(
      "init_data, expect_str, expect_fmt, expect_flat, expect_last, expect_bool",
      (
          (
              [
                  (1, [Version("1.1.1"), Version("1.1.2")]),
                  (2, [Version("1.2.1"), Version("1.2.2")]),
              ],
              "[[Version('1.1.1'), Version('1.1.2')], [Version('1.2.1'), Version('1.2.2')]]",
              "1.1.1 1.1.2\n1.2.1 1.2.2",
              [Version("1.1.1"), Version("1.1.2"), Version("1.2.1"), Version("1.2.2")],
              Version("1.2.2"),
              True,
          ),
          (
              [],
              "[]",
              "",
              [],
              None,
              False,
          ),
          (
              Version("1.2.3"),
              "[[Version('1.2.3')]]",
              "1.2.3",
              [Version("1.2.3")],
              Version("1.2.3"),
              True,
          ),
      ),
  )
  def test_versions(init_data, expect_str, expect_fmt, expect_flat, expect_last, expect_bool):
      versions = Versions(init_data)
      assert str(versions) == expect_str
      assert format(versions) == expect_fmt
      assert format(versions, "s") == expect_str
      assert versions.flattened() == expect_flat
      assert versions.latest() == expect_last
      assert bool(versions) == expect_bool
  
      with pytest.raises(TypeError) as pytest_wrapped_e:
          format(versions, "x")
      assert pytest_wrapped_e.type == TypeError
  
  
  @pytest.fixture
  def spec_regex():
      return re.compile(r"^(\d+\.\d+)")
  
  
  @pytest.mark.parametrize(
      "os_name,target,in_file,expect_out_file",
      [
          ("windows", "android", "windows-android.html", "windows-android-expect.json"),
          ("windows", "desktop", "windows-desktop.html", "windows-desktop-expect.json"),
          ("windows", "winrt", "windows-winrt.html", "windows-winrt-expect.json"),
          ("linux", "android", "linux-android.html", "linux-android-expect.json"),
          ("linux", "desktop", "linux-desktop.html", "linux-desktop-expect.json"),
          ("mac", "android", "mac-android.html", "mac-android-expect.json"),
          ("mac", "desktop", "mac-desktop.html", "mac-desktop-expect.json"),
          ("mac", "ios", "mac-ios.html", "mac-ios-expect.json"),
      ],
  )
  def test_list_versions_tools(monkeypatch, spec_regex, os_name, target, in_file, expect_out_file):
      _html = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _html)
  
      expected = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      # Test 'aqt list-tool'
      tools = MetadataFactory(ArchiveId("tools", os_name, target)).getList()
      assert tools == expected["tools"]
  
      for ext, expected_output in expected["qt"].items():
          # Test 'aqt list-qt'
          archive_id = ArchiveId("qt", os_name, target, ext if ext != "qt" else "")
          all_versions = MetadataFactory(archive_id).getList()
  
          if len(expected_output) == 0:
              assert not all_versions
          else:
              assert f"{all_versions}" == "\n".join(expected_output)
  
          # Filter for the latest version only
          latest_ver = MetadataFactory(archive_id, is_latest_version=True).getList()
  
          if len(expected_output) == 0:
              assert not latest_ver
          else:
              assert f"{latest_ver}" == expected_output[-1].split(" ")[-1]
  
          for row in expected_output:
              spec_str = spec_regex.search(row).group(1)
              spec = SimpleSpec(spec_str) if not ext.endswith("preview") else SimpleSpec(spec_str + ".0-preview")
  
              # Find the latest version for a particular spec
              latest_ver_for_spec = MetadataFactory(
                  archive_id,
                  spec=spec,
                  is_latest_version=True,
              ).getList()
              assert f"{latest_ver_for_spec}" == row.split(" ")[-1]
  
              # Find all versions for a particular spec
              all_ver_for_spec = MetadataFactory(
                  archive_id,
                  spec=spec,
              ).getList()
              assert f"{all_ver_for_spec}" == row
  
  
  @pytest.mark.parametrize(
      "version,extension,in_file,expect_out_file",
      [
          ("5.14.0", "", "windows-5140-update.xml", "windows-5140-expect.json"),
          ("5.15.0", "", "windows-5150-update.xml", "windows-5150-expect.json"),
          (
              "5.15.2",
              "src_doc_examples",
              "windows-5152-src-doc-example-update.xml",
              "windows-5152-src-doc-example-expect.json",
          ),
          ("6.2.0", "", "windows-620-update.xml", "windows-620-expect.json"),
      ],
  )
  def test_list_architectures_and_modules(monkeypatch, version: str, extension: str, in_file: str, expect_out_file: str):
      archive_id = ArchiveId("qt", "windows", "desktop", extension)
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
  
      for arch in expect["architectures"]:
          modules = MetadataFactory(archive_id).fetch_modules(Version(version), arch)
          assert modules == sorted(expect["modules_by_arch"][arch])
  
      arches = MetadataFactory(archive_id).fetch_arches(Version(version))
      assert arches == expect["architectures"]
  
  
  @pytest.fixture
  def win_5152_sde_xml_file() -> str:
      return (Path(__file__).parent / "data" / "windows-5152-src-doc-example-update.xml").read_text("utf-8")
  
  
  def win_5152_sde_expected(cmd_type: str, query_type: str) -> Set[str]:
      assert cmd_type in ("src", "doc", "examples")
      assert query_type in ("archives", "modules")
      _json = json.loads((Path(__file__).parent / "data/windows-5152-src-doc-example-expect.json").read_text("utf-8"))
      return set(_json[cmd_type][query_type])
  
  
  @pytest.mark.parametrize(
      "cmd_type, host, version, expected",
      [
          (
              _cmd_type,
              "windows",
              "5.15.2",
              win_5152_sde_expected(_cmd_type, "archives"),
          )
          for _cmd_type in ("src", "doc", "examples")
      ],
  )
  def test_list_src_doc_examples_archives(
      monkeypatch, win_5152_sde_xml_file, cmd_type: str, host: str, version: str, expected: Set[str]
  ):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      archive_id = ArchiveId("qt", host, "desktop", "src_doc_examples")
      archives = set(MetadataFactory(archive_id).fetch_archives_sde(cmd_type, Version(version)))
      assert archives == expected
  
  
  @pytest.mark.parametrize(
      "cmd_type, host, version, expected",
      [
          (
              _cmd_type,
              "windows",
              "5.15.2",
              win_5152_sde_expected(_cmd_type, "modules"),
          )
          for _cmd_type in ("doc", "examples")
      ],
  )
  def test_list_src_doc_examples_modules(
      monkeypatch, win_5152_sde_xml_file, cmd_type: str, host: str, version: str, expected: Set[str]
  ):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      archive_id = ArchiveId("qt", host, "desktop", "src_doc_examples")
      modules = set(MetadataFactory(archive_id).fetch_modules_sde(cmd_type, Version(version)))
      assert modules == expected
  
  
  @pytest.mark.parametrize(
      "command, expected",
      (
          ("list-src windows 5.15.2", win_5152_sde_expected("src", "archives")),
          ("list-doc windows 5.15.2", win_5152_sde_expected("doc", "archives")),
          ("list-example windows 5.15.2", win_5152_sde_expected("examples", "archives")),
          ("list-doc windows 5.15.2 --modules", win_5152_sde_expected("doc", "modules")),
          ("list-example windows 5.15.2 --modules", win_5152_sde_expected("examples", "modules")),
      ),
  )
  def test_list_src_doc_examples_cli(monkeypatch, capsys, win_5152_sde_xml_file, command: str, expected: Set[str]):
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: win_5152_sde_xml_file)
  
      cli = Cli()
      assert 0 == cli.run(command.split())
      out, err = capsys.readouterr()
      assert not err
      out_set = set(out.strip().split())
      assert out_set == expected
  
  
  @pytest.mark.parametrize(
      "version, arch, modules_to_query, modules_failed_query",
      (
          ("5.14.0", "win32_mingw73", [], []),
          ("5.14.0", "win32_mingw73", ["qtcharts"], []),
          ("5.14.0", "win32_mingw73", ["all"], []),
          ("5.14.0", "win32_mingw73", ["debug_info"], ["debug_info"]),
          ("5.14.0", "win64_msvc2017_64", [], []),
          ("5.14.0", "win64_msvc2017_64", ["debug_info"], []),
      ),
  )
  def test_list_archives(
      monkeypatch, capsys, version: str, arch: str, modules_to_query: List[str], modules_failed_query: List[str]
  ):
      archive_id = ArchiveId("qt", "windows", "desktop")
      in_file = f"{archive_id.host}-{version.replace('.', '')}-update.xml"
      expect_out_file = f"{archive_id.host}-{version.replace('.', '')}-expect.json"
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      if not modules_to_query:
          expected_qt_archives = expect["qt_base_pkgs_by_arch"][arch]["DownloadableArchives"]
          expected = set([arc.split("-")[0] for arc in expected_qt_archives])
      else:
          expected_mod_metadata = expect["modules_metadata_by_arch"][arch]
          if "all" not in modules_to_query:
              expected_mod_metadata = filter(lambda mod: mod["Name"].split(".")[-2] in modules_to_query, expected_mod_metadata)
          expected = set([arc.split("-")[0] for mod in expected_mod_metadata for arc in mod["DownloadableArchives"]])
  
      archives_query = [version, arch, *modules_to_query]
      cli_args = ["list-qt", "windows", "desktop", "--archives", *archives_query]
      if not modules_failed_query:
          meta = set(MetadataFactory(archive_id, archives_query=archives_query).getList())
          assert meta == expected
  
          cli = Cli()
          assert 0 == cli.run(cli_args)
          out, err = capsys.readouterr()
          assert out.rstrip() == " ".join(sorted(expected))
          return
  
      expected_err_msg = f"The requested modules were not located: {sorted(modules_failed_query)}"
      with pytest.raises(CliInputError) as err:
          MetadataFactory(archive_id, archives_query=archives_query).getList()
      assert err.type == CliInputError
      assert format(err.value).startswith(expected_err_msg)
  
      cli = Cli()
      assert 1 == cli.run(cli_args)
      out, err = capsys.readouterr()
      assert expected_err_msg in err
  
  
  def test_list_archives_insufficient_args(capsys):
      cli = Cli()
      assert 1 == cli.run("list-qt mac desktop --archives 5.14.0".split())
      out, err = capsys.readouterr()
      assert err.strip() == "The '--archives' flag requires a 'QT_VERSION' and an 'ARCHITECTURE' parameter."
  
  
  def test_list_archives_bad_xml(monkeypatch):
      archive_id = ArchiveId("qt", "windows", "desktop")
      archives_query = ["5.15.2", "win32_mingw81", "qtcharts"]
  
      xml_no_name = "<Updates><PackageUpdate><badname></badname></PackageUpdate></Updates>"
      xml_empty_name = "<Updates><PackageUpdate><Name></Name></PackageUpdate></Updates>"
      xml_broken = "<Updates></PackageUpdate><PackageUpdate></Updates><Name></Name>"
  
      for _xml in (xml_no_name, xml_empty_name, xml_broken):
          monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
          with pytest.raises(ArchiveListError) as e:
              MetadataFactory(archive_id, archives_query=archives_query).getList()
          assert e.type == ArchiveListError
  
  
  @pytest.mark.parametrize(
      "host, target, tool_name",
      [
          ("mac", "desktop", "tools_cmake"),
          ("mac", "desktop", "tools_ifw"),
          ("mac", "desktop", "tools_qtcreator"),
      ],
  )
  def test_tool_modules(monkeypatch, host: str, target: str, tool_name: str):
      archive_id = ArchiveId("tools", host, target)
      in_file = "{}-{}-{}-update.xml".format(host, target, tool_name)
      expect_out_file = "{}-{}-{}-expect.json".format(host, target, tool_name)
      _xml = (Path(__file__).parent / "data" / in_file).read_text("utf-8")
      expect = json.loads((Path(__file__).parent / "data" / expect_out_file).read_text("utf-8"))
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", lambda self, _: _xml)
  
      modules = MetadataFactory(archive_id, tool_name=tool_name).getList()
      assert modules == expect["modules"]
  
      table = MetadataFactory(archive_id, tool_name=tool_name, is_long_listing=True).getList()
      assert table._rows() == expect["long_listing"]
  
  
  @pytest.fixture
  def expected_windows_desktop_5140() -> Dict:
      xmlexpect = "windows-5140-expect.json"
      return json.loads((Path(__file__).parent / "data" / xmlexpect).read_text("utf-8"))
  
  
  @pytest.mark.parametrize(
      "args, expect",
      (
          ("--extensions latest", {"src_doc_examples"}),
          ("--spec 5.14 --extensions latest", {"wasm", "src_doc_examples"}),
          ("--extensions 5.14.0", {"wasm", "src_doc_examples"}),
          ("--modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--spec 5.14 --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--modules 5.14.0 win32_mingw73", ["modules_by_arch", "win32_mingw73"]),
          ("--modules 5.14.0 win32_msvc2017", ["modules_by_arch", "win32_msvc2017"]),
          ("--modules 5.14.0 win64_mingw73", ["modules_by_arch", "win64_mingw73"]),
          ("--modules 5.14.0 win64_msvc2015_64", ["modules_by_arch", "win64_msvc2015_64"]),
          ("--modules 5.14.0 win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --spec 5.14 --modules latest win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--extension wasm --modules 5.14.0 win64_msvc2017_64", ["modules_by_arch", "win64_msvc2017_64"]),
          ("--arch latest", ["architectures"]),
          ("--spec 5.14 --arch latest", ["architectures"]),
          ("--arch 5.14.0", ["architectures"]),
          ("--extension wasm --arch latest", ["architectures"]),
          ("--extension wasm --spec 5.14 --arch latest", ["architectures"]),
          ("--extension wasm --arch 5.14.0", ["architectures"]),
      ),
  )
  def test_list_qt_cli(
      monkeypatch,
      capsys,
      expected_windows_desktop_5140: Dict[str, Set[str]],
      args: str,
      expect: Union[Set[str], List[str]],
  ):
      htmlfile, xmlfile = "windows-desktop.html", "windows-5140-update.xml"
      version_string_to_replace = "qt5.5140"
      if isinstance(expect, list):
          # In this case, 'expect' is a list of keys to follow to the expected values.
          expected_dict = expected_windows_desktop_5140
          for key in expect:  # Follow the chain of keys to the list of values.
              expected_dict = expected_dict[key]
          assert isinstance(expected_dict, list)
          expect_set = set(expected_dict)
      else:
          expect_set = expect
      assert isinstance(expect_set, set)
  
      def _mock_fetch_http(_, rest_of_url: str) -> str:
          htmltext = (Path(__file__).parent / "data" / htmlfile).read_text("utf-8")
          if not rest_of_url.endswith("Updates.xml"):
              return htmltext
  
          xmltext = (Path(__file__).parent / "data" / xmlfile).read_text("utf-8")
          # If we are serving an Updates.xml, `aqt list` will look for a Qt version number.
          # We will replace the version numbers in the file with the requested version.
          match = re.search(r"qt(\d)_(\d+)", rest_of_url)
          assert match
          major, version_nodot = match.groups()
          desired_version_string = f"qt{major}.{version_nodot}"
          return xmltext.replace(version_string_to_replace, desired_version_string)
  
      monkeypatch.setattr(MetadataFactory, "fetch_http", _mock_fetch_http)
  
      cli = Cli()
      cli.run(["list-qt", "windows", "desktop", *args.split()])
      out, err = capsys.readouterr()
      output_set = set(out.strip().split())
      assert output_set == expect_set
  
  
  @pytest.mark.parametrize(
      "cmd, host, expect",
      (
          ("list-qt", "windows", {"desktop", "android", "winrt"}),
          ("list-qt", "linux", {"desktop", "android"}),
          ("list-qt", "mac", {"desktop", "android", "ios"}),
          ("list-tool", "windows", {"desktop", "android", "winrt"}),
          ("list-tool", "linux", {"desktop", "android"}),
          ("list-tool", "mac", {"desktop", "android", "ios"}),
      ),
  )
  def test_list_targets(capsys, cmd: str, host: str, expect: Set[str]):
      cli = Cli()
      cli.run([cmd, host])
      out, err = capsys.readouterr()
      output_set = set(out.strip().split())
      assert output_set == expect
  
  
  @pytest.mark.parametrize(
      "cmd, host, target",
      (
  
  
  [... the rest is omitted ...]
files: tests/test_list.py