Repository: miurahr/aqtinstall

Issue:
Keeping v1 commands available in v2 may confuse users
## Overview

In [Legacy subcommands § Command Line Options — aqtinstall 2.0.0 documentation](https://aqtinstall.readthedocs.io/en/v2.0.0/cli.html#legacy-subcommands), we wrote:

> The subcommands `install`, `tool`, `src`, `doc`, and `examples` have been deprecated in favor of the newer `install-*` commands, but they remain in aqt in case you still need to use them.

After reading the above paragraph, I will think *"Fine, 'aqt tool' is still there. Those guys are keeping two series of commands in v2. I don't have to update my script after upgrading aqtinstall"*. However, the reality is that after switching to v2, the old script will **NOT** work without modification, at least for the "tool" command.

## Test

A working example for installing QIFW by aqtinstall 1.2.5:

```console
$ aqt tool linux tools_ifw 4.1.1 qt.tools.ifw.41
Specified target combination is not valid: linux tools_ifw qt.tools.ifw.41
...
Time elapsed: 8.37262754 second
```

The same script does not work in 2.0.0:

```console
$ aqt tool linux tools_ifw 4.1.1 qt.tools.ifw.41
usage: aqt tool ...
...
aqt tool: error: argument target: invalid choice: 'tools_ifw' (choose from 'desktop', 'winrt', 'android', 'ios')
Error: Process completed with exit code 2.
```

Rewrite the script according to the documentation of "[install-tool](https://aqtinstall.readthedocs.io/en/v2.0.0/cli.html#install-tool-command)" but keep the *legacy* "tool" command. It works in 2.0.0:

```diff
- aqt tool linux         tools_ifw 4.1.1 qt.tools.ifw.41
+ aqt tool linux desktop tools_ifw
```

```console
$ aqt tool linux desktop tools_ifw
aqtinstall(aqt) v2.0.0 on Python 3.8.10 [CPython GCC 9.4.0]
Warning: The command 'tool' is deprecated and marked for removal in a future version of aqt.
In the future, please use the command 'install-tool' instead.
...
Time elapsed: 11.00345962 second
```

Add the "tool variant name":

```diff
- aqt tool linux desktop tools_ifw
+ aqt tool linux desktop tools_ifw qt.tools.ifw.41
```

```console
$ aqt tool linux desktop tools_ifw qt.tools.ifw.41
aqtinstall(aqt) v2.0.0 on Python 3.8.10 [CPython GCC 9.4.0]
Warning: The command 'tool' is deprecated and marked for removal in a future version of aqt.
In the future, please use the command 'install-tool' instead.
...
Time elapsed: 9.82558412 second
```

*Use the command 'install-tool' instead*:

```diff
- aqt tool         linux desktop tools_ifw qt.tools.ifw.41
+ aqt install-tool linux desktop tools_ifw qt.tools.ifw.41
```

```console
$ aqt install-tool linux desktop tools_ifw qt.tools.ifw.41
aqtinstall(aqt) v2.0.0 on Python 3.8.10 [CPython GCC 9.4.0]
...
Time elapsed: 11.23552921 second
```

## Conclusion

As you can see, in aqtinstall v2, "tool" is not a legacy subcommand but **another name** of "install-tool". Those old interfaces have been changed, so keeping them available in v2 may confuse users.

I really know that the API changes can be (or should be) made in a new major release, but we didn't keep the availability and compatibility for those legacy commands, and what's worse is that we didn't document it correctly and clearly.

## Ignorable possible solutions

Here are some expensive ways to solve this problem:

- Remove those legacy commands in the next major version and release this new version as soon as possible. Yes, aqtinstall v3.
- Or, make those legacy commands usable again in the next minor version of v2. In other words, keep two series (v1 and v2) of commands in aqtinstall ^v2.1.


Comments:

Patch:
diff --git a/aqt/archives.py b/aqt/archives.py
index 3c06d8c..2874747 100644
--- a/aqt/archives.py
+++ b/aqt/archives.py
@@ -371,6 +371,7 @@ class ToolArchives(QtArchives):
         self.tool_name = tool_name
         self.os_name = os_name
         self.logger = getLogger("aqt.archives")
+        self.is_require_version_match = version_str is not None
         super(ToolArchives, self).__init__(
             os_name=os_name,
             target=target,
@@ -421,6 +422,9 @@ class ToolArchives(QtArchives):
 
         name = packageupdate.find("Name").text
         named_version = packageupdate.find("Version").text
+        if self.is_require_version_match and named_version != self.version:
+            message = f"The package '{self.arch}' has the version '{named_version}', not the requested '{self.version}'."
+            raise NoPackageFound(message, suggested_action=self.help_msg())
         package_desc = packageupdate.find("Description").text
         downloadable_archives = packageupdate.find("DownloadableArchives").text
         if not downloadable_archives:
diff --git a/aqt/installer.py b/aqt/installer.py
index 1500705..2059c0e 100644
--- a/aqt/installer.py
+++ b/aqt/installer.py
@@ -66,7 +66,7 @@ class Cli:
     def __init__(self):
         parser = argparse.ArgumentParser(
             prog="aqt",
-            description="Another unofficial Qt Installer.\n" "aqt helps you install Qt SDK, tools, examples and others\n",
+            description="Another unofficial Qt Installer.\naqt helps you install Qt SDK, tools, examples and others\n",
             formatter_class=argparse.RawTextHelpFormatter,
             add_help=True,
         )
@@ -84,11 +84,7 @@ class Cli:
             "commands {install|tool|src|examples|doc} are deprecated and marked for removal\n",
             help="Please refer to each help message by using '--help' with each subcommand",
         )
-        self._make_install_parsers(subparsers)
-        self._make_list_qt_parser(subparsers)
-        self._make_list_tool_parser(subparsers)
-        self._make_legacy_parsers(subparsers)
-        self._make_common_parsers(subparsers)
+        self._make_all_parsers(subparsers)
         parser.set_defaults(func=self.show_help)
         self.parser = parser
 
@@ -388,7 +384,7 @@ class Cli:
             self._warn_on_deprecated_command("tool", "install-tool")
         tool_name = args.tool_name  # such as tools_openssl_x64
         os_name = args.host  # windows, linux and mac
-        target = args.target  # desktop, android and ios
+        target = "desktop" if args.is_legacy else args.target  # desktop, android and ios
         output_dir = args.outputdir
         if output_dir is None:
             base_dir = os.getcwd()
@@ -398,7 +394,8 @@ class Cli:
         if EXT7Z and sevenzip is None:
             # override when py7zr is not exist
             sevenzip = self._set_sevenzip(Settings.zipcmd)
-        version = "0.0.1"  # just store a dummy version
+        version = getattr(args, "version", "0.0.1")  # for legacy aqt tool
+        Cli._validate_version_str(version)
         keep = args.keep
         if args.base is not None:
             base = args.base
@@ -408,7 +405,7 @@ class Cli:
             timeout = (args.timeout, args.timeout)
         else:
             timeout = (Settings.connection_timeout, Settings.response_timeout)
-        if args.arch is None:
+        if args.tool_variant is None:
             archive_id = ArchiveId("tools", os_name, target, "")
             meta = MetadataFactory(archive_id, is_latest_version=True, tool_name=tool_name)
             try:
@@ -418,7 +415,7 @@ class Cli:
                 raise ArchiveListError(msg, suggested_action=suggested_follow_up(meta)) from e
 
         else:
-            archs = [args.arch]
+            archs = [args.tool_variant]
 
         for arch in archs:
             if not self._check_tools_arg_combination(os_name, tool_name, arch):
@@ -541,71 +538,67 @@ class Cli:
     def _set_install_tool_parser(self, install_tool_parser, *, is_legacy: bool):
         install_tool_parser.set_defaults(func=self.run_install_tool, is_legacy=is_legacy)
         install_tool_parser.add_argument("host", choices=["linux", "mac", "windows"], help="host os name")
-        install_tool_parser.add_argument(
-            "target",
-            default=None,
-            choices=["desktop", "winrt", "android", "ios"],
-            help="Target SDK.",
-        )
+        if not is_legacy:
+            install_tool_parser.add_argument(
+                "target",
+                default=None,
+                choices=["desktop", "winrt", "android", "ios"],
+                help="Target SDK.",
+            )
         install_tool_parser.add_argument("tool_name", help="Name of tool such as tools_ifw, tools_mingw")
+        if is_legacy:
+            install_tool_parser.add_argument("version", help="Version of tool variant")
+
+        tool_variant_opts = {} if is_legacy else {"nargs": "?", "default": None}
         install_tool_parser.add_argument(
-            "arch",
-            nargs="?",
-            default=None,
-            help="Name of full tool name such as qt.tools.ifw.31. "
+            "tool_variant",
+            **tool_variant_opts,
+            help="Name of tool variant, such as qt.tools.ifw.41. "
             "Please use 'aqt list-tool' to list acceptable values for this parameter.",
         )
         self._set_common_options(install_tool_parser)
 
-    def _make_legacy_parsers(self, subparsers: argparse._SubParsersAction):
-        deprecated_msg = "This command is deprecated and marked for removal in a future version of aqt."
-        install_parser = subparsers.add_parser(
-            "install",
-            description=deprecated_msg,
-            formatter_class=argparse.RawTextHelpFormatter,
-        )
-        self._set_install_qt_parser(install_parser, is_legacy=True)
-        tool_parser = subparsers.add_parser("tool")
-        self._set_install_tool_parser(tool_parser, is_legacy=True)
-        #
-        for cmd, f in (
-            ("doc", self.run_install_doc),
-            ("example", self.run_install_example),
-            ("src", self.run_install_src),
-        ):
-            p = subparsers.add_parser(cmd, description=deprecated_msg)
-            p.set_defaults(func=f, is_legacy=True)
-            self._set_common_arguments(p, is_legacy=True)
-            self._set_common_options(p)
-            self._set_module_options(p)
-
     def _warn_on_deprecated_command(self, old_name: str, new_name: str):
         self.logger.warning(
             f"Warning: The command '{old_name}' is deprecated and marked for removal in a future version of aqt.\n"
             f"In the future, please use the command '{new_name}' instead."
         )
 
-    def _make_install_parsers(self, subparsers: argparse._SubParsersAction):
-        install_qt_parser = subparsers.add_parser("install-qt", formatter_class=argparse.RawTextHelpFormatter)
-        self._set_install_qt_parser(install_qt_parser, is_legacy=False)
-        tool_parser = subparsers.add_parser("install-tool")
-        self._set_install_tool_parser(tool_parser, is_legacy=False)
-        #
-        for cmd, f in (
-            ("install-doc", self.run_install_doc),
-            ("install-example", self.run_install_example),
-        ):
-            p = subparsers.add_parser(cmd)
-            p.set_defaults(func=f, is_legacy=False)
-            self._set_common_arguments(p, is_legacy=False)
-            self._set_common_options(p)
-            self._set_module_options(p)
-        src_parser = subparsers.add_parser("install-src")
-        src_parser.set_defaults(func=self.run_install_src, is_legacy=False)
-        self._set_common_arguments(src_parser, is_legacy=False)
-        self._set_common_options(src_parser)
-        self._set_module_options(src_parser)
-        src_parser.add_argument("--kde", action="store_true", help="patching with KDE patch kit.")
+    def _make_all_parsers(self, subparsers: argparse._SubParsersAction):
+        deprecated_msg = "This command is deprecated and marked for removal in a future version of aqt."
+
+        def make_parser_it(cmd: str, desc: str, is_legacy: bool, set_parser_cmd, formatter_class):
+            description = f"{desc} {deprecated_msg}" if is_legacy else desc
+            kwargs = {"formatter_class": formatter_class} if formatter_class else {}
+            p = subparsers.add_parser(cmd, description=description, **kwargs)
+            set_parser_cmd(p, is_legacy=is_legacy)
+
+        def make_parser_sde(cmd: str, desc: str, is_legacy: bool, action, is_add_kde: bool):
+            description = f"{desc} {deprecated_msg}" if is_legacy else desc
+            parser = subparsers.add_parser(cmd, description=description)
+            parser.set_defaults(func=action, is_legacy=is_legacy)
+            self._set_common_arguments(parser, is_legacy=is_legacy)
+            self._set_common_options(parser)
+            self._set_module_options(parser)
+            if is_add_kde:
+                parser.add_argument("--kde", action="store_true", help="patching with KDE patch kit.")
+
+        make_parser_it("install-qt", "Install Qt.", False, self._set_install_qt_parser, argparse.RawTextHelpFormatter)
+        make_parser_it("install-tool", "Install tools.", False, self._set_install_tool_parser, None)
+        make_parser_sde("install-doc", "Install documentation.", False, self.run_install_doc, False)
+        make_parser_sde("install-example", "Install examples.", False, self.run_install_example, False)
+        make_parser_sde("install-src", "Install source.", False, self.run_install_src, True)
+
+        self._make_list_qt_parser(subparsers)
+        self._make_list_tool_parser(subparsers)
+
+        make_parser_it("install", "Install Qt.", True, self._set_install_qt_parser, argparse.RawTextHelpFormatter)
+        make_parser_it("tool", "Install tools.", True, self._set_install_tool_parser, None)
+        make_parser_sde("doc", "Install documentation.", True, self.run_install_doc, False)
+        make_parser_sde("examples", "Install examples.", True, self.run_install_example, False)
+        make_parser_sde("src", "Install source.", True, self.run_install_src, True)
+
+        self._make_common_parsers(subparsers)
 
     def _make_list_qt_parser(self, subparsers: argparse._SubParsersAction):
         """Creates a subparser that works with the MetadataFactory, and adds it to the `subparsers` parameter"""


Test patch:
diff --git a/tests/test_archives.py b/tests/test_archives.py
index 66020e0..af52a9f 100644
--- a/tests/test_archives.py
+++ b/tests/test_archives.py
@@ -232,6 +232,38 @@ def test_tools_variants(monkeypatch, tool_name, tool_variant_name, is_expect_fai
     assert len(expected_7z_files) == 0, f"Failed to produce QtPackages for {expected_7z_files}"
 
 
+def to_xml(package_updates: Iterable[Dict]) -> str:
+    def wrap(tag: str, content: str, is_multiline: bool = True):
+        newline = "\n" if is_multiline else ""
+        return f"<{tag}>{newline}{content}{newline}</{tag}>"
+
+    return wrap(
+        "Updates",
+        "\n".join(
+            [
+                wrap("PackageUpdate", "\n".join([wrap(key, value, False) for key, value in pu.items()]))
+                for pu in package_updates
+            ]
+        ),
+    )
+
+
+@pytest.mark.parametrize(
+    "tool_name, variant_name, version, actual_version",
+    (("tools_qtcreator", "qt.tools.qtcreator", "1.2.3", "3.2.1"),),
+)
+def test_tool_archive_wrong_version(monkeypatch, tool_name, variant_name, version, actual_version):
+    def _mock(self, *args):
+        self.update_xml_text = to_xml([dict(Name=variant_name, Version=actual_version)])
+
+    monkeypatch.setattr(QtArchives, "_download_update_xml", _mock)
+
+    host, target, base = "mac", "desktop", "https://example.com"
+    with pytest.raises(NoPackageFound) as e:
+        ToolArchives(host, target, tool_name, base, version_str=version, arch=variant_name)
+    assert e.type == NoPackageFound
+
+
 # Test the helper class
 def test_module_to_package():
     qt_base_names = ["qt.999.clang", "qt9.999.clang", "qt9.999.addon.clang"]
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4d90566..09427ca 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -14,7 +14,7 @@ def expected_help():
     return (
         "usage: aqt [-h] [-c CONFIG]\n"
         "           {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,"
-        "install,tool,doc,example,src,help,version}\n"
+        "install,tool,doc,examples,src,help,version}\n"
         "           ...\n"
         "\n"
         "Another unofficial Qt Installer.\n"
@@ -33,7 +33,7 @@ def expected_help():
         "  commands {install|tool|src|examples|doc} are deprecated and marked for removal\n"
         "\n"
         "  {install-qt,install-tool,install-doc,install-example,install-src,list-qt,list-tool,"
-        "install,tool,doc,example,src,help,version}\n"
+        "install,tool,doc,examples,src,help,version}\n"
         "                        Please refer to each help message by using '--help' with each subcommand\n"
     )
 
@@ -218,6 +218,74 @@ def test_cli_input_errors(capsys, expected_help, cmd, expect_msg, should_show_he
     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")