Skip to content

Commit

Permalink
Add install-qt-commercial feature and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
Kidev committed Jan 6, 2025
1 parent 7917b2d commit 4180492
Show file tree
Hide file tree
Showing 2 changed files with 246 additions and 3 deletions.
201 changes: 200 additions & 1 deletion aqt/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,17 @@
import subprocess
import sys
import tarfile
import tempfile
import time
import zipfile
from logging import getLogger
from logging import Logger, getLogger
from logging.handlers import QueueHandler
from pathlib import Path
from tempfile import TemporaryDirectory
from typing import List, Optional, Tuple, cast

import requests

import aqt
from aqt.archives import QtArchives, QtPackage, SrcDocExamplesArchives, ToolArchives
from aqt.exceptions import (
Expand Down Expand Up @@ -657,6 +660,33 @@ def run_list_src_doc_examples(self, args: ListArgumentParser, cmd_type: str):
)
show_list(meta)

def run_install_qt_commercial(self, args):

Check failure on line 663 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Function is missing a type annotation Raw Output: aqt/installer.py:663:5: error: Function is missing a type annotation
"""Execute commercial Qt installation"""
self.show_aqt_version()

Check failure on line 665 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Call to untyped function "show_aqt_version" in Raw Output: aqt/installer.py:665:9: error: Call to untyped function "show_aqt_version" in

target = args.target
arch = args.arch
version = args.version
username = args.user
password = args.password
output_dir = args.outputdir

commercial_installer = CommercialInstaller(
target=target,
arch=arch,
version=version,
username=username,
password=password,
output_dir=output_dir,
logger=self.logger,
)

try:
commercial_installer.install()

Check failure on line 685 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Call to untyped function "install" in typed Raw Output: aqt/installer.py:685:13: error: Call to untyped function "install" in typed
except Exception as e:
self.logger.error(f"Commercial installation failed: {str(e)}")
raise

def show_help(self, args=None):
"""Display help message"""
self.parser.print_help()
Expand Down Expand Up @@ -750,6 +780,31 @@ def _set_install_tool_parser(self, install_tool_parser):
)
self._set_common_options(install_tool_parser)

def _set_install_qt_commercial_parser(self, install_qt_commercial_parser):

Check failure on line 783 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Function is missing a type annotation Raw Output: aqt/installer.py:783:5: error: Function is missing a type annotation
install_qt_commercial_parser.set_defaults(func=self.run_install_qt_commercial)
install_qt_commercial_parser.add_argument(
"target",
choices=["desktop", "android", "ios"],
help="Target platform",
)
install_qt_commercial_parser.add_argument(
"arch",
help="Target architecture",
)
install_qt_commercial_parser.add_argument(
"version",
help="Qt version",
)
install_qt_commercial_parser.add_argument(
"--user",
help="Qt account username",
)
install_qt_commercial_parser.add_argument(
"--password",
help="Qt account password",
)
self._set_common_options(install_qt_commercial_parser)

Check failure on line 806 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Call to untyped function "_set_common_options" Raw Output: aqt/installer.py:806:9: error: Call to untyped function "_set_common_options"

def _warn_on_deprecated_command(self, old_name: str, new_name: str) -> None:
self.logger.warning(
f"The command '{old_name}' is deprecated and marked for removal in a future version of aqt.\n"
Expand All @@ -764,6 +819,7 @@ def _warn_on_deprecated_parameter(self, parameter_name: str, value: str):
)

def _make_all_parsers(self, subparsers: argparse._SubParsersAction) -> None:
"""Creates all command parsers and adds them to the subparsers"""

def make_parser_it(cmd: str, desc: str, set_parser_cmd, formatter_class):
kwargs = {"formatter_class": formatter_class} if formatter_class else {}
Expand Down Expand Up @@ -798,12 +854,20 @@ def make_parser_list_sde(cmd: str, desc: str, cmd_type: str):
if cmd_type != "src":
parser.add_argument("-m", "--modules", action="store_true", help="Print list of available modules")

# Create install command parsers
make_parser_it("install-qt", "Install Qt.", self._set_install_qt_parser, argparse.RawTextHelpFormatter)
make_parser_it("install-tool", "Install tools.", self._set_install_tool_parser, None)
make_parser_it(
"install-qt-commercial",
"Install Qt commercial.",
self._set_install_qt_commercial_parser,
argparse.RawTextHelpFormatter,
)
make_parser_sde("install-doc", "Install documentation.", self.run_install_doc, False)
make_parser_sde("install-example", "Install examples.", self.run_install_example, False)
make_parser_sde("install-src", "Install source.", self.run_install_src, True, is_add_modules=False)

# Create list command parsers
self._make_list_qt_parser(subparsers)
self._make_list_tool_parser(subparsers)
make_parser_list_sde("list-doc", "List documentation archives available (use with install-doc)", "doc")
Expand Down Expand Up @@ -1313,3 +1377,138 @@ def download_bin(_base_url):
qh.flush()
qh.close()
logger.removeHandler(qh)


class CommercialInstaller:
def __init__(
self,
target: str,
arch: str,
version: str,
username: Optional[str] = None,
password: Optional[str] = None,
output_dir: Optional[str] = None,
logger: Optional[Logger] = None,
):
self.target = target
self.arch = arch
self.version = Version(version)
self.username = username
self.password = password
self.output_dir = output_dir
self.logger = logger or getLogger(__name__)

# Map platform names consistently
system = platform.system()
if system == "Darwin":
self.os_name = "mac"
elif system == "Linux":
self.os_name = "linux"
else:
self.os_name = "windows"

self.installer_filename = self._get_installer_filename()
self.qt_account = self._get_qt_account_path()

def _get_installer_filename(self) -> str:
"""Get OS-specific installer filename"""
base = "qt-unified"

if self.os_name == "windows":
return f"{base}-windows-x64-online.exe"
elif self.os_name == "mac":
return f"{base}-macOS-x64-online.dmg"
else:
return f"{base}-linux-x64-online.run"

def _get_qt_account_path(self) -> Path:
"""Get OS-specific qtaccount.ini path"""
if self.os_name == "windows":
return Path(os.environ["APPDATA"]) / "Qt" / "qtaccount.ini"
elif self.os_name == "mac":
return Path.home() / "Library" / "Application Support" / "Qt" / "qtaccount.ini"
else:
return Path.home() / ".local" / "share" / "Qt" / "qtaccount.ini"

def _download_installer(self, target_path: Path):

Check failure on line 1433 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Function is missing a return type annotation Raw Output: aqt/installer.py:1433:5: error: Function is missing a return type annotation
"""Download Qt online installer"""
url = f"https://download.qt.io/official_releases/online_installers/{self.installer_filename}"

try:
response = requests.get(url, stream=True)

Check warning on line 1438 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1438

Requests call without timeout
response.raise_for_status()

total = response.headers.get("content-length", 0)

with open(target_path, "wb") as f:
if total:
desc = f"Downloading {self.installer_filename}"
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)

if self.os_name != "windows":
os.chmod(target_path, 0o755)

Check failure on line 1450 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1450

Chmod setting a permissive mask 0o755 on file (target_path).

Check warning on line 1450 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1450

These permissions `0o755` are widely permissive and grant access to more people than may be necessary.

except requests.exceptions.RequestException as e:
raise ArchiveDownloadError(f"Failed to download installer: {str(e)}")

def _get_package_name(self) -> str:
"""Convert aqt parameters to Qt package name"""
qt_version = f"{self.version.major}{self.version.minor}{self.version.patch}"
return f"qt.qt{self.version.major}.{qt_version}.{self.arch}"

def _get_install_command(self, installer_path: Path) -> list:

Check failure on line 1460 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Missing type parameters for generic type Raw Output: aqt/installer.py:1460:61: error: Missing type parameters for generic type
"""Build installation command"""
cmd = [str(installer_path)]

# Authentication
if self.username and self.password:
cmd.extend(["--email", self.username, "--pw", self.password])

# Installation directory
if self.output_dir:
cmd.extend(["--root", str(self.output_dir)])

# Unattended options
cmd.extend(
[
"--accept-licenses",
"--accept-obligations",
"--confirm-command",
"--default-answer",
"install",
self._get_package_name(),
]
)

return cmd

def install(self):

Check failure on line 1486 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 error: Function is missing a return type annotation Raw Output: aqt/installer.py:1486:5: error: Function is missing a return type annotation

Check failure on line 1486 in aqt/installer.py

View workflow job for this annotation

GitHub Actions / Linter

[mypy] reported by reviewdog 🐶 note: Use "-> None" if function does not return a value Raw Output: aqt/installer.py:1486:5: note: Use "-> None" if function does not return a value
"""Run commercial installation"""
# Verify auth
if not self.qt_account.exists() and not (self.username and self.password):
raise CliInputError(
"No Qt account credentials found. Either provide --user and --password "
f"or ensure {self.qt_account} exists"
)

# Create temp dir for installer
with tempfile.TemporaryDirectory() as temp_dir:
installer_path = Path(temp_dir) / self.installer_filename

# Download installer
self.logger.info(f"Downloading Qt online installer to {installer_path}")
self._download_installer(installer_path)

# Run installation
self.logger.info("Starting Qt installation")
cmd = self._get_install_command(installer_path)

self.logger.info(f"Running: {cmd}")

try:
subprocess.check_call(cmd)

Check failure on line 1510 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1510

Detected subprocess function 'check_call' without a static string.

Check failure on line 1510 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1510

Python possesses many mechanisms to invoke an external executable.

Check warning on line 1510 in aqt/installer.py

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

aqt/installer.py#L1510

subprocess call - check for execution of untrusted input.
except subprocess.CalledProcessError as e:
raise CliInputError(f"Qt installation failed with code {e.returncode}")

self.logger.info("Qt installation completed successfully")
48 changes: 46 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import platform
import re
import sys
from pathlib import Path
Expand All @@ -15,7 +16,8 @@
def expected_help(actual, prefix=None):
expected = (
"usage: aqt [-h] [-c CONFIG]\n"
" {install-qt,install-tool,install-doc,install-example,install-src,"
" {install-qt,install-tool,install-qt-commercial,install-doc,install-example,"
"install-src,"
"list-qt,list-tool,list-doc,list-example,list-src,help,version}\n"
" ...\n"
"\n"
Expand All @@ -32,7 +34,8 @@ def expected_help(actual, prefix=None):
" install-* subcommands are commands that install components\n"
" list-* subcommands are commands that show available components\n"
"\n"
" {install-qt,install-tool,install-doc,install-example,install-src,list-qt,"
" {install-qt,install-tool,install-qt-commercial,install-doc,install-example,"
"install-src,list-qt,"
"list-tool,list-doc,list-example,list-src,help,version}\n"
" Please refer to each help message by using '--help' "
"with each subcommand\n",
Expand Down Expand Up @@ -520,3 +523,44 @@ def test_get_autodesktop_dir_and_arch_non_android(
), "Expected autodesktop install message."
elif expect["instruct"]:
assert any("You can install" in line for line in err_lines), "Expected install instruction message."


@pytest.mark.parametrize(
"cmd, expected_arch, expected_err",
[
pytest.param(
"install-qt-commercial desktop {} 6.8.0",
{"windows": "win64_msvc2022_64", "linux": "gcc_64", "mac": "clang_64"},
"No Qt account credentials found. Either provide --user and --password or",
id="basic-commercial-install",
),
],
)
def test_cli_install_qt_commercial(capsys, monkeypatch, cmd, expected_arch, expected_err):
"""Test commercial Qt installation command"""
# Detect current platform
current_platform = platform.system().lower()
arch = expected_arch[current_platform]
cmd = cmd.format(arch)

# Mock platform-specific paths
def mock_exists(*args, **kwargs):
return False

monkeypatch.setattr(Path, "exists", mock_exists)

# Mock subprocess calls
def mock_subprocess(*args, **kwargs):
return 0

monkeypatch.setattr("subprocess.check_call", mock_subprocess)

# Run the command
cli = Cli()
cli._setup_settings()
result = cli.run(cmd.split())

# Check outputs
out, err = capsys.readouterr()
assert expected_err in err
assert result == 1 # Should fail due to missing credentials

0 comments on commit 4180492

Please sign in to comment.