Add support for commercial versions of Qt (#878)

* Add install-qt-commercial feature and tests

* Make the auto-answers parameters, fix linter issues

* Fork and execv instead of using subprocess

* Return to simpler process execution method version

* Fix test

* Move commercial installer into its own file

* Fix shadowing of symbol platform causing errors

* Adapt test_cli for argparse format changes on py 3.13+

* Fix some errors, monkeypatch install test

* Add --override super command

* Properly handle --override and grab all the remaining commands when no quotes are given

* Fix tests

* Add base for modules, some niche features are not yet entirely implemented, and there are no updates to the testsuite

* Fix some mistakes

* Fix errors made with the monkeypatch, update Settings to make sure its init

* Tests commercial (#20)

* Full support of installation of all modules and addons
* Add auto setup of cache folder for each OS, add unattended parameter
* Fix settings folders
* Add graceful error message for overwrite case

* Fix windows issue

* Hidden summon works

* Remove both subprocess direct calls

* Dipose of temp folder

* Fix path issue

* Add list-qt-commercial command

* Fix help info

* Make no params valid for list-qt-commercial

* Fix lint errors, and param overflow when no args are passed to list

* Fix search

* Add tests for coverage, fix lint

* Test for overwriting, and for cache usage coverage

* Return to clean exec, ignoring CI fail to preserve code clarity

* Fix parsing of subprocess.run output for some python versions

* Make output more readable in console for list-qt-commercial

* Forward email and password to list request for users without a qtaccount.ini

* Change default settings

* Fix lint errors

* Fix check error
This commit is contained in:
Alexandre Poumaroux
2025-01-28 12:51:53 +01:00
committed by GitHub
parent 7917b2d725
commit cbe159f38a
8 changed files with 933 additions and 54 deletions

2
.gitignore vendored
View File

@@ -18,3 +18,5 @@ Qt/
.eggs
qtaccount.ini
.pytest_cache
.run/
.python-version

362
aqt/commercial.py Normal file
View File

@@ -0,0 +1,362 @@
import json
import os
from dataclasses import dataclass
from logging import Logger, getLogger
from pathlib import Path
from typing import List, Optional
import requests
from defusedxml import ElementTree
from aqt.exceptions import DiskAccessNotPermitted
from aqt.helper import Settings, get_os_name, get_qt_account_path, get_qt_installer_name, safely_run, safely_run_save_output
from aqt.metadata import Version
@dataclass
class QtPackageInfo:
name: str
displayname: str
version: str
class QtPackageManager:
def __init__(
self, arch: str, version: Version, target: str, username: Optional[str] = None, password: Optional[str] = None
):
self.arch = arch
self.version = version
self.target = target
self.cache_dir = self._get_cache_dir()
self.packages: List[QtPackageInfo] = []
self.username = username
self.password = password
def _get_cache_dir(self) -> Path:
"""Create and return cache directory path."""
base_cache = Settings.qt_installer_cache_path
cache_path = os.path.join(base_cache, self.target, self.arch, str(self.version))
Path(cache_path).mkdir(parents=True, exist_ok=True)
return Path(cache_path)
def _get_cache_file(self) -> Path:
"""Get the cache file path."""
return self.cache_dir / "packages.json"
def _save_to_cache(self) -> None:
"""Save packages information to cache."""
cache_data = [{"name": pkg.name, "displayname": pkg.displayname, "version": pkg.version} for pkg in self.packages]
with open(self._get_cache_file(), "w") as f:
json.dump(cache_data, f, indent=2)
def _load_from_cache(self) -> bool:
"""Load packages information from cache if available."""
cache_file = self._get_cache_file()
if not cache_file.exists():
return False
try:
with open(cache_file, "r") as f:
cache_data = json.load(f)
self.packages = [
QtPackageInfo(name=pkg["name"], displayname=pkg["displayname"], version=pkg["version"])
for pkg in cache_data
]
return True
except (json.JSONDecodeError, KeyError):
return False
def _parse_packages_xml(self, xml_content: str) -> None:
"""Parse packages XML content and extract package information using defusedxml."""
try:
# Use defusedxml.ElementTree to safely parse the XML content
root = ElementTree.fromstring(xml_content)
self.packages = []
# Find all package elements using XPath-like expression
# Note: defusedxml supports a subset of XPath
for pkg in root.findall(".//package"):
name = pkg.get("name", "")
displayname = pkg.get("displayname", "")
version = pkg.get("version", "")
if all([name, displayname, version]): # Ensure all required attributes are present
self.packages.append(QtPackageInfo(name=name, displayname=displayname, version=version))
except ElementTree.ParseError as e:
raise RuntimeError(f"Failed to parse package XML: {e}")
def _get_version_string(self) -> str:
"""Get formatted version string for package names."""
return f"{self.version.major}{self.version.minor}{self.version.patch}"
def _get_base_package_name(self) -> str:
"""Get the base package name for the current configuration."""
version_str = self._get_version_string()
return f"qt.qt{self.version.major}.{version_str}"
def gather_packages(self, installer_path: str) -> None:
"""Gather package information using qt installer search command."""
if self._load_from_cache():
return
version_str = self._get_version_string()
base_package = f"qt.qt{self.version.major}.{version_str}"
cmd = [
installer_path,
"--accept-licenses",
"--accept-obligations",
"--confirm-command",
"--default-answer",
"search",
base_package,
]
if self.username and self.password:
cmd.extend(["--email", self.username, "--pw", self.password])
try:
output = safely_run_save_output(cmd, Settings.qt_installer_timeout)
# Handle both string and CompletedProcess outputs
output_text = output.stdout if hasattr(output, "stdout") else str(output)
# Extract the XML portion from the output
xml_start = output_text.find("<availablepackages>")
xml_end = output_text.find("</availablepackages>") + len("</availablepackages>")
if xml_start != -1 and xml_end != -1:
xml_content = output_text[xml_start:xml_end]
self._parse_packages_xml(xml_content)
self._save_to_cache()
else:
# Log the actual output for debugging
logger = getLogger("aqt.helper")
logger.debug(f"Installer output: {output_text}")
raise RuntimeError("Failed to find package information in installer output")
except Exception as e:
raise RuntimeError(f"Failed to get package information: {str(e)}")
def get_install_command(self, modules: Optional[List[str]], temp_dir: str) -> List[str]:
"""Generate installation command based on requested modules."""
package_name = f"{self._get_base_package_name()}.{self.arch}"
cmd = ["install", package_name]
# No modules requested, return base package only
if not modules:
return cmd
# Ensure package cache exists
self.gather_packages(temp_dir)
if "all" in modules:
# Find all addon and direct module packages
for pkg in self.packages:
if f"{self._get_base_package_name()}.addons." in pkg.name or pkg.name.startswith(
f"{self._get_base_package_name()}."
):
module_name = pkg.name.split(".")[-1]
if module_name != self.arch: # Skip the base package
cmd.append(pkg.name)
else:
# Add specifically requested modules that exist in either format
for module in modules:
addon_name = f"{self._get_base_package_name()}.addons.{module}"
direct_name = f"{self._get_base_package_name()}.{module}"
# Check if either package name exists
matching_pkg = next(
(pkg.name for pkg in self.packages if pkg.name == addon_name or pkg.name == direct_name), None
)
if matching_pkg:
cmd.append(matching_pkg)
return cmd
class CommercialInstaller:
"""Qt Commercial installer that handles module installation and package management."""
def __init__(
self,
target: str,
arch: Optional[str],
version: Optional[str],
username: Optional[str] = None,
password: Optional[str] = None,
output_dir: Optional[str] = None,
logger: Optional[Logger] = None,
base_url: str = "https://download.qt.io",
override: Optional[list[str]] = None,
modules: Optional[List[str]] = None,
no_unattended: bool = False,
):
self.override = override
self.target = target
self.arch = arch or ""
self.version = Version(version) if version else Version("0.0.0")
self.username = username
self.password = password
self.output_dir = output_dir
self.logger = logger or getLogger(__name__)
self.base_url = base_url
self.modules = modules
self.no_unattended = no_unattended
# Set OS-specific properties
self.os_name = get_os_name()
self._installer_filename = get_qt_installer_name()
self.qt_account = get_qt_account_path()
self.package_manager = QtPackageManager(self.arch, self.version, self.target, self.username, self.password)
@staticmethod
def get_auto_answers() -> str:
"""Get auto-answer options from settings."""
settings_map = {
"OperationDoesNotExistError": Settings.qt_installer_operationdoesnotexisterror,
"OverwriteTargetDirectory": Settings.qt_installer_overwritetargetdirectory,
"stopProcessesForUpdates": Settings.qt_installer_stopprocessesforupdates,
"installationErrorWithCancel": Settings.qt_installer_installationerrorwithcancel,
"installationErrorWithIgnore": Settings.qt_installer_installationerrorwithignore,
"AssociateCommonFiletypes": Settings.qt_installer_associatecommonfiletypes,
"telemetry-question": Settings.qt_installer_telemetry,
}
answers = []
for key, value in settings_map.items():
answers.append(f"{key}={value}")
return ",".join(answers)
@staticmethod
def build_command(
installer_path: str,
override: Optional[List[str]] = None,
username: Optional[str] = None,
password: Optional[str] = None,
output_dir: Optional[str] = None,
no_unattended: bool = False,
) -> List[str]:
"""Build the installation command with proper safeguards."""
cmd = [installer_path]
# Add unattended flags unless explicitly disabled
if not no_unattended:
cmd.extend(["--accept-licenses", "--accept-obligations", "--confirm-command"])
if override:
# When using override, still include unattended flags unless disabled
cmd.extend(override)
return cmd
# Add authentication if provided
if username and password:
cmd.extend(["--email", username, "--pw", password])
# Add output directory if specified
if output_dir:
cmd.extend(["--root", str(Path(output_dir).resolve())])
# Add auto-answer options from settings
auto_answers = CommercialInstaller.get_auto_answers()
if auto_answers:
cmd.extend(["--auto-answer", auto_answers])
return cmd
def install(self) -> None:
"""Run the Qt installation process."""
if (
not self.qt_account.exists()
and not (self.username and self.password)
and not os.environ.get("QT_INSTALLER_JWT_TOKEN")
):
raise RuntimeError(
"No Qt account credentials found. Provide username and password or ensure qtaccount.ini exists."
)
# Check output directory if specified
if self.output_dir:
output_path = Path(self.output_dir) / str(self.version)
if output_path.exists():
if Settings.qt_installer_overwritetargetdirectory.lower() == "yes":
self.logger.warning(f"Target directory {output_path} exists - removing as overwrite is enabled")
try:
import shutil
shutil.rmtree(output_path)
except (OSError, PermissionError) as e:
raise DiskAccessNotPermitted(f"Failed to remove existing target directory {output_path}: {str(e)}")
else:
msg = (
f"Target directory {output_path} already exists. "
"Set overwrite_target_directory='Yes' in settings.ini to overwrite, or select another directory."
)
raise DiskAccessNotPermitted(msg)
# Setup cache directory
cache_path = Path(Settings.qt_installer_cache_path)
cache_path.mkdir(parents=True, exist_ok=True)
import shutil
temp_dir = Settings.qt_installer_temp_path
temp_path = Path(temp_dir)
if temp_path.exists():
shutil.rmtree(temp_dir)
temp_path.mkdir(parents=True, exist_ok=True)
installer_path = temp_path / self._installer_filename
self.logger.info(f"Downloading Qt installer to {installer_path}")
self.download_installer(installer_path, Settings.qt_installer_timeout)
try:
cmd = []
if self.override:
cmd = self.build_command(str(installer_path), override=self.override, no_unattended=self.no_unattended)
else:
# Initialize package manager and gather packages
self.package_manager.gather_packages(str(installer_path))
base_cmd = self.build_command(
str(installer_path.absolute()),
username=self.username,
password=self.password,
output_dir=self.output_dir,
no_unattended=self.no_unattended,
)
cmd = [
*base_cmd,
*self.package_manager.get_install_command(self.modules, temp_dir),
]
self.logger.info(f"Running: {cmd}")
safely_run(cmd, Settings.qt_installer_timeout)
except Exception as e:
self.logger.error(f"Installation failed with exit code {e.__str__()}")
raise
finally:
self.logger.info("Qt installation completed successfully")
def download_installer(self, target_path: Path, timeout: int) -> None:
url = f"{self.base_url}/official_releases/online_installers/{self._installer_filename}"
try:
response = requests.get(url, stream=True, timeout=timeout)
response.raise_for_status()
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if self.os_name != "windows":
os.chmod(target_path, 0o500)
except Exception as e:
raise RuntimeError(f"Failed to download installer: {e}")
def _get_package_name(self) -> str:
qt_version = f"{self.version.major}{self.version.minor}{self.version.patch}"
return f"qt.qt{self.version.major}.{qt_version}.{self.arch}"

View File

@@ -24,6 +24,8 @@ import logging.config
import os
import posixpath
import secrets
import shutil
import subprocess
import sys
from configparser import ConfigParser
from logging import Handler, getLogger
@@ -48,6 +50,64 @@ from aqt.exceptions import (
)
def get_os_name() -> str:
system = sys.platform.lower()
if system == "darwin":
return "mac"
if system == "linux":
return "linux"
if system in ("windows", "win32"): # Accept both windows and win32
return "windows"
raise ValueError(f"Unsupported operating system: {system}")
def get_qt_local_folder_path() -> Path:
os_name = get_os_name()
if os_name == "windows":
appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))
return Path(appdata) / "Qt"
if os_name == "mac":
return Path.home() / "Library" / "Application Support" / "Qt"
return Path.home() / ".local" / "share" / "Qt"
def get_qt_account_path() -> Path:
return get_qt_local_folder_path() / "qtaccount.ini"
def get_qt_installer_name() -> str:
installer_dict = {
"windows": "qt-unified-windows-x64-online.exe",
"mac": "qt-unified-macOS-x64-online.dmg",
"linux": "qt-unified-linux-x64-online.run",
}
return installer_dict[get_os_name()]
def get_qt_installer_path() -> Path:
return get_qt_local_folder_path() / get_qt_installer_name()
def get_default_local_cache_path() -> Path:
os_name = get_os_name()
if os_name == "windows":
appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))
return Path(appdata) / "aqt" / "cache"
if os_name == "mac":
return Path.home() / "Library" / "Application Support" / "aqt" / "cache"
return Path.home() / ".local" / "share" / "aqt" / "cache"
def get_default_local_temp_path() -> Path:
os_name = get_os_name()
if os_name == "windows":
appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming"))
return Path(appdata) / "aqt" / "tmp"
if os_name == "mac":
return Path.home() / "Library" / "Application Support" / "aqt" / "tmp"
return Path.home() / ".local" / "share" / "aqt" / "tmp"
def _get_meta(url: str) -> requests.Response:
return requests.get(url + ".meta4")
@@ -344,24 +404,41 @@ class SettingsClass:
"_lock": Lock(),
}
def __init__(self) -> None:
self.config: Optional[ConfigParser]
self._lock: Lock
self._initialize()
def __new__(cls, *p, **k):
self = object.__new__(cls, *p, **k)
self.__dict__ = cls._shared_state
return self
def __init__(self) -> None:
self.config: Optional[ConfigParser]
self._lock: Lock
def _initialize(self) -> None:
"""Initialize configuration if not already initialized."""
if self.config is None:
with self._lock:
if self.config is None:
self.config = MyConfigParser()
self.configfile = os.path.join(os.path.dirname(__file__), "settings.ini")
self.loggingconf = os.path.join(os.path.dirname(__file__), "logging.ini")
self.config.read(self.configfile)
logging.info(f"Cache folder: {self.qt_installer_cache_path}")
logging.info(f"Temp folder: {self.qt_installer_temp_path}")
if Path(self.qt_installer_temp_path).exists():
shutil.rmtree(self.qt_installer_temp_path)
def _get_config(self) -> ConfigParser:
"""Safe getter for config that ensures it's initialized."""
self._initialize()
assert self.config is not None
return self.config
def load_settings(self, file: Optional[Union[str, TextIO]] = None) -> None:
if self.config is None:
return
if file is not None:
if isinstance(file, str):
result = self.config.read(file)
@@ -377,6 +454,24 @@ class SettingsClass:
with open(self.configfile, "r") as f:
self.config.read_file(f)
@property
def qt_installer_cache_path(self) -> str:
"""Path for Qt installer cache."""
config = self._get_config()
# If no cache_path or blank, return default without modifying config
if not config.has_option("qtcommercial", "cache_path") or config.get("qtcommercial", "cache_path").strip() == "":
return str(get_default_local_cache_path())
return config.get("qtcommercial", "cache_path")
@property
def qt_installer_temp_path(self) -> str:
"""Path for Qt installer cache."""
config = self._get_config()
# If no cache_path or blank, return default without modifying config
if not config.has_option("qtcommercial", "temp_path") or config.get("qtcommercial", "temp_path").strip() == "":
return str(get_default_local_temp_path())
return config.get("qtcommercial", "temp_path")
@property
def archive_download_location(self):
return self.config.get("aqt", "archive_download_location", fallback=".")
@@ -473,6 +568,58 @@ class SettingsClass:
"""
return self.config.getint("aqt", "min_module_size", fallback=41)
# Qt Commercial Installer properties
@property
def qt_installer_timeout(self) -> int:
"""Timeout for Qt commercial installer operations in seconds."""
return self._get_config().getint("qtcommercial", "installer_timeout", fallback=3600)
@property
def qt_installer_operationdoesnotexisterror(self) -> str:
"""Handle OperationDoesNotExistError in Qt installer."""
return self._get_config().get("qtcommercial", "operation_does_not_exist_error", fallback="Ignore")
@property
def qt_installer_overwritetargetdirectory(self) -> str:
"""Handle overwriting target directory in Qt installer."""
return self._get_config().get("qtcommercial", "overwrite_target_directory", fallback="No")
@property
def qt_installer_stopprocessesforupdates(self) -> str:
"""Handle stopping processes for updates in Qt installer."""
return self._get_config().get("qtcommercial", "stop_processes_for_updates", fallback="Cancel")
@property
def qt_installer_installationerrorwithcancel(self) -> str:
"""Handle installation errors with cancel option in Qt installer."""
return self._get_config().get("qtcommercial", "installation_error_with_cancel", fallback="Cancel")
@property
def qt_installer_installationerrorwithignore(self) -> str:
"""Handle installation errors with ignore option in Qt installer."""
return self._get_config().get("qtcommercial", "installation_error_with_ignore", fallback="Ignore")
@property
def qt_installer_associatecommonfiletypes(self) -> str:
"""Handle file type associations in Qt installer."""
return self._get_config().get("qtcommercial", "associate_common_filetypes", fallback="Yes")
@property
def qt_installer_telemetry(self) -> str:
"""Handle telemetry settings in Qt installer."""
return self._get_config().get("qtcommercial", "telemetry", fallback="No")
@property
def qt_installer_unattended(self) -> bool:
"""Control whether to use unattended installation flags."""
return self._get_config().getboolean("qtcommercial", "unattended", fallback=True)
def qt_installer_cleanup(self) -> None:
"""Control whether to use unattended installation flags."""
import shutil
shutil.rmtree(self.qt_installer_temp_path)
Settings = SettingsClass()
@@ -482,3 +629,18 @@ def setup_logging(env_key="LOG_CFG"):
if config is not None and os.path.exists(config):
Settings.loggingconf = config
logging.config.fileConfig(Settings.loggingconf)
def safely_run(cmd: List[str], timeout: int) -> None:
try:
subprocess.run(cmd, shell=False, timeout=timeout)
except Exception:
raise
def safely_run_save_output(cmd: List[str], timeout: int) -> Any:
try:
result = subprocess.run(cmd, shell=False, capture_output=True, text=True, timeout=timeout)
return result
except Exception:
raise

View File

@@ -42,6 +42,7 @@ from typing import List, Optional, Tuple, cast
import aqt
from aqt.archives import QtArchives, QtPackage, SrcDocExamplesArchives, ToolArchives
from aqt.commercial import CommercialInstaller
from aqt.exceptions import (
AqtException,
ArchiveChecksumError,
@@ -59,8 +60,11 @@ from aqt.helper import (
Settings,
downloadBinaryFile,
get_hash,
get_os_name,
get_qt_installer_name,
retry_on_bad_connection,
retry_on_errors,
safely_run_save_output,
setup_logging,
)
from aqt.metadata import ArchiveId, MetadataFactory, QtRepoProperty, SimpleSpec, Version, show_list, suggested_follow_up
@@ -124,9 +128,20 @@ class CommonInstallArgParser(BaseArgumentParser):
class InstallArgParser(CommonInstallArgParser):
"""Install-qt arguments and options"""
override: Optional[List[str]]
arch: Optional[str]
qt_version: str
qt_version_spec: str
version: Optional[str]
user: Optional[str]
password: Optional[str]
operation_does_not_exist_error: str
overwrite_target_dir: str
stop_processes_for_updates: str
installation_error_with_cancel: str
installation_error_with_ignore: str
associate_common_filetypes: str
telemetry: str
modules: Optional[List[str]]
archives: Optional[List[str]]
@@ -657,6 +672,47 @@ class Cli:
)
show_list(meta)
def run_install_qt_commercial(self, args: InstallArgParser) -> None:
"""Execute commercial Qt installation"""
self.show_aqt_version()
if args.override:
commercial_installer = CommercialInstaller(
target="", # Empty string as placeholder
arch="",
version=None,
logger=self.logger,
base_url=args.base if args.base is not None else Settings.baseurl,
override=args.override,
no_unattended=not Settings.qt_installer_unattended,
)
else:
if not all([args.target, args.arch, args.version]):
raise CliInputError("target, arch, and version are required")
commercial_installer = CommercialInstaller(
target=args.target,
arch=args.arch,
version=args.version,
username=args.user,
password=args.password,
output_dir=args.outputdir,
logger=self.logger,
base_url=args.base if args.base is not None else Settings.baseurl,
no_unattended=not Settings.qt_installer_unattended,
modules=args.modules,
)
try:
commercial_installer.install()
Settings.qt_installer_cleanup()
except DiskAccessNotPermitted:
# Let DiskAccessNotPermitted propagate up without additional logging
raise
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()
@@ -667,7 +723,7 @@ class Cli:
py_build = platform.python_compiler()
return f"aqtinstall(aqt) v{aqt.__version__} on Python {py_version} [{py_impl} {py_build}]"
def show_aqt_version(self, args=None):
def show_aqt_version(self, args: Optional[list[str]] = None) -> None:
"""Display version information"""
self.logger.info(self._format_aqt_version())
@@ -750,6 +806,134 @@ class Cli:
)
self._set_common_options(install_tool_parser)
def _set_install_qt_commercial_parser(self, install_qt_commercial_parser: argparse.ArgumentParser) -> None:
install_qt_commercial_parser.set_defaults(func=self.run_install_qt_commercial)
# Create mutually exclusive group for override vs standard parameters
exclusive_group = install_qt_commercial_parser.add_mutually_exclusive_group()
exclusive_group.add_argument(
"--override",
nargs=argparse.REMAINDER,
help="Will ignore all other parameters and use everything after this parameter as "
"input for the official Qt installer",
)
# Make standard arguments optional when override is used by adding a custom action
class ConditionalRequiredAction(argparse.Action):
def __call__(self, parser, namespace, values, option_string=None) -> None:
if not hasattr(namespace, "override") or not namespace.override:
setattr(namespace, self.dest, values)
install_qt_commercial_parser.add_argument(
"target",
nargs="?",
choices=["desktop", "android", "ios"],
help="Target platform",
action=ConditionalRequiredAction,
)
install_qt_commercial_parser.add_argument(
"arch", nargs="?", help="Target architecture", action=ConditionalRequiredAction
)
install_qt_commercial_parser.add_argument("version", nargs="?", help="Qt version", action=ConditionalRequiredAction)
install_qt_commercial_parser.add_argument(
"--user",
help="Qt account username",
)
install_qt_commercial_parser.add_argument(
"--password",
help="Qt account password",
)
install_qt_commercial_parser.add_argument(
"--modules",
nargs="*",
help="Add modules",
)
self._set_common_options(install_qt_commercial_parser)
def _make_list_qt_commercial_parser(self, subparsers: argparse._SubParsersAction) -> None:
"""Creates a subparser for listing Qt commercial packages"""
list_parser = subparsers.add_parser(
"list-qt-commercial",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Examples:\n"
"$ aqt list-qt-commercial # list all available packages\n"
"$ aqt list-qt-commercial gcc_64 # search for specific archs\n"
"$ aqt list-qt-commercial 6.8.1 # search for specific versions\n"
"$ aqt list-qt-commercial qtquick3d # search for specific packages\n"
"$ aqt list-qt-commercial gcc_64 6.8.1 # search for multiple terms at once\n",
)
list_parser.add_argument(
"search_terms",
nargs="*",
help="Optional search terms to pass to the installer search command. If not provided, lists all packages",
)
list_parser.set_defaults(func=self.run_list_qt_commercial)
def run_list_qt_commercial(self, args) -> None:
"""Execute Qt commercial package listing"""
self.show_aqt_version()
# Create temporary directory to download installer
import shutil
from pathlib import Path
temp_dir = Settings.qt_installer_temp_path
temp_path = Path(temp_dir)
if temp_path.exists():
shutil.rmtree(temp_dir)
temp_path.mkdir(parents=True, exist_ok=True)
# Get installer based on OS
installer_filename = get_qt_installer_name()
installer_path = temp_path / installer_filename
try:
# Download installer
self.logger.info(f"Downloading Qt installer to {installer_path}")
base_url = Settings.baseurl
url = f"{base_url}/official_releases/online_installers/{installer_filename}"
import requests
response = requests.get(url, stream=True, timeout=Settings.qt_installer_timeout)
response.raise_for_status()
with open(installer_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
if get_os_name() != "windows":
os.chmod(installer_path, 0o500)
# Build search command
cmd = [
str(installer_path),
"--accept-licenses",
"--accept-obligations",
"--confirm-command",
"search",
"" if not args.search_terms else " ".join(args.search_terms),
]
# Run search and display output
output = safely_run_save_output(cmd, Settings.qt_installer_timeout)
# Process and print the output properly
if output.stdout:
# Print the actual output with proper newlines
print(output.stdout)
# If there are any errors, print them as warnings
if output.stderr:
for line in output.stderr.splitlines():
self.logger.warning(line)
except Exception as e:
self.logger.error(f"Failed to list Qt commercial packages: {e}")
finally:
# Clean up
Settings.qt_installer_cleanup()
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"
@@ -764,6 +948,7 @@ class Cli:
)
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 {}
@@ -798,13 +983,22 @@ class Cli:
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_qt_commercial_parser(subparsers)
self._make_list_tool_parser(subparsers)
make_parser_list_sde("list-doc", "List documentation archives available (use with install-doc)", "doc")
make_parser_list_sde("list-example", "List example archives available (use with install-example)", "examples")
@@ -948,14 +1142,13 @@ class Cli:
)
list_parser.set_defaults(func=self.run_list_tool)
def _make_common_parsers(self, subparsers: argparse._SubParsersAction):
def _make_common_parsers(self, subparsers: argparse._SubParsersAction) -> None:
help_parser = subparsers.add_parser("help")
help_parser.set_defaults(func=self.show_help)
#
version_parser = subparsers.add_parser("version")
version_parser.set_defaults(func=self.show_aqt_version)
def _set_common_options(self, subparser):
def _set_common_options(self, subparser: argparse.ArgumentParser) -> None:
subparser.add_argument(
"-O",
"--outputdir",
@@ -1236,7 +1429,8 @@ def run_installer(
listener.stop()
def init_worker_sh():
def init_worker_sh() -> None:
"""Initialize worker signal handling"""
signal.signal(signal.SIGINT, signal.SIG_IGN)
@@ -1248,7 +1442,7 @@ def installer(
archive_dest: Path,
settings_ini: str,
keep: bool,
):
) -> None:
"""
Installer function to download archive files and extract it.
It is called through multiprocessing.Pool()

View File

@@ -1,32 +1,45 @@
[DEFAULTS]
[aqt]
concurrency: 4
baseurl: https://download.qt.io
7zcmd: 7z
print_stacktrace_on_error: False
always_keep_archives: False
archive_download_location: .
min_module_size: 41
concurrency : 4
baseurl : https://download.qt.io
7zcmd : 7z
print_stacktrace_on_error : False
always_keep_archives : False
archive_download_location : .
min_module_size : 41
[requests]
connection_timeout: 3.5
response_timeout: 30
max_retries_on_connection_error: 5
retry_backoff: 0.1
max_retries_on_checksum_error: 5
max_retries_to_retrieve_hash: 5
hash_algorithm: sha256
INSECURE_NOT_FOR_PRODUCTION_ignore_hash: False
connection_timeout : 3.5
response_timeout : 30
max_retries_on_connection_error : 5
retry_backoff : 0.1
max_retries_on_checksum_error : 5
max_retries_to_retrieve_hash : 5
hash_algorithm : sha256
INSECURE_NOT_FOR_PRODUCTION_ignore_hash : False
[qtcommercial]
unattended : True
installer_timeout : 1800
operation_does_not_exist_error : Ignore
overwrite_target_directory : Yes
stop_processes_for_updates : Ignore
installation_error_with_cancel : Ignore
installation_error_with_ignore : Ignore
associate_common_filetypes : Yes
telemetry : No
cache_path :
temp_dir :
[mirrors]
trusted_mirrors:
trusted_mirrors :
https://download.qt.io
blacklist:
blacklist :
http://mirrors.ocf.berkeley.edu
http://mirrors.tuna.tsinghua.edu.cn
http://mirrors.geekpie.club
fallbacks:
fallbacks :
https://qtproject.mirror.liquidtelecom.com/
https://mirrors.aliyun.com/qt/
https://mirrors.ustc.edu.cn/qtproject/
@@ -44,7 +57,7 @@ fallbacks:
https://qt.mirror.constant.com/
[kde_patches]
patches:
patches :
0001-toolchain.prf-Use-vswhere-to-obtain-VS-installation-.patch
0002-Fix-allocated-memory-of-QByteArray-returned-by-QIODe.patch
0003-Update-CLDR-to-v37-adding-Nigerian-Pidgin-as-a-new-l.patch
@@ -240,4 +253,4 @@ patches:
0193-Remove-the-unnecessary-template-parameter-from-the-c.patch
0194-Fix-memory-leak-when-using-small-caps-font.patch
0195-Make-sure-_q_printerChanged-is-called-even-if-only-p.patch
0196-fix-Alt-shortcut-on-non-US-layouts.patch
0196-fix-Alt-shortcut-on-non-US-layouts.patch

View File

@@ -1,3 +1,4 @@
import platform
import re
import sys
from pathlib import Path
@@ -15,8 +16,9 @@ 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,help,version}\n"
" {install-qt,install-tool,install-qt-commercial,install-doc,install-example,"
"install-src,"
"list-qt,list-qt-commercial,list-tool,list-doc,list-example,list-src,help,version}\n"
" ...\n"
"\n"
"Another unofficial Qt Installer.\n"
@@ -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-qt-commercial,"
"list-tool,list-doc,list-example,list-src,help,version}\n"
" Please refer to each help message by using '--help' "
"with each subcommand\n",
@@ -520,3 +523,29 @@ 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": "linux_gcc_64", "mac": "clang_64"},
"No Qt account credentials found. Either provide --user and --password or",
),
],
)
def test_cli_login_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)
cli = Cli()
cli._setup_settings()
result = cli.run(cmd.split())
_, err = capsys.readouterr()
assert str(err).find(expected_err)
assert not result == 0

View File

@@ -1676,30 +1676,30 @@ def test_install_qt6_wasm_autodesktop(monkeypatch, capsys, version, str_version,
assert result == 0
# Check output format
out, err = capsys.readouterr()
sys.stdout.write(out)
sys.stderr.write(err)
# Check output format
out, err = capsys.readouterr()
sys.stdout.write(out)
sys.stderr.write(err)
# Use regex that works for all platforms
expected_pattern = re.compile(
r"^INFO : aqtinstall\(aqt\) v.*? on Python 3.*?\n"
r"INFO : You are installing the Qt6-WASM version of Qt\n"
r"(?:INFO : Found extension .*?\n)*"
r"(?:INFO : Downloading (?:qt[^\n]*|icu[^\n]*)\n"
r"Finished installation of .*?\.7z in \d+\.\d+\n)*"
r"(?:INFO : Patching (?:/tmp/[^/]+|[A-Za-z]:[\\/].*?)/6\.8\.0/wasm_singlethread/bin/(?:qmake|qtpaths)(?:6)?\n)*"
r"INFO : \n"
r"INFO : Autodesktop will now install linux desktop 6\.8\.0 linux_gcc_64 as required by Qt6-WASM\n"
r"INFO : aqtinstall\(aqt\) v.*? on Python 3.*?\n"
r"(?:INFO : Found extension .*?\n)*"
r"(?:INFO : Downloading (?:qt[^\n]*|icu[^\n]*)\n"
r"Finished installation of .*?\.7z in \d+\.\d+\n)*"
r"INFO : Finished installation\n"
r"INFO : Time elapsed: \d+\.\d+ second\n$"
)
# Use regex that works for all platforms
expected_pattern = re.compile(
r"^INFO : aqtinstall\(aqt\) v.*? on Python 3.*?\n"
r"INFO : You are installing the Qt6-WASM version of Qt\n"
r"(?:INFO : Found extension .*?\n)*"
r"(?:INFO : Downloading (?:qt[^\n]*|icu[^\n]*)\n"
r"Finished installation of .*?\.7z in \d+\.\d+\n)*"
r"(?:INFO : Patching (?:/tmp/[^/]+|[A-Za-z]:[\\/].*?)/6\.8\.0/wasm_singlethread/bin/(?:qmake|qtpaths)(?:6)?\n)*"
r"INFO : \n"
r"INFO : Autodesktop will now install linux desktop 6\.8\.0 linux_gcc_64 as required by Qt6-WASM\n"
r"INFO : aqtinstall\(aqt\) v.*? on Python 3.*?\n"
r"(?:INFO : Found extension .*?\n)*"
r"(?:INFO : Downloading (?:qt[^\n]*|icu[^\n]*)\n"
r"Finished installation of .*?\.7z in \d+\.\d+\n)*"
r"INFO : Finished installation\n"
r"INFO : Time elapsed: \d+\.\d+ second\n$"
)
assert expected_pattern.match(err)
assert expected_pattern.match(err)
@pytest.mark.parametrize(
@@ -2054,3 +2054,104 @@ def test_installer_passes_base_to_metadatafactory(
sys.stderr.write(err)
assert expect_out.match(err), err
class CompletedProcess:
def __init__(self, args, returncode):
self.args = args
self.returncode = returncode
self.stdout = None
self.stderr = None
@pytest.mark.enable_socket
@pytest.mark.parametrize(
"cmd, arch_dict, details, expected_command",
[
(
"install-qt-commercial desktop {} 6.8.0 " "--outputdir ./install-qt-commercial " "--user {} --password {}",
{"windows": "win64_msvc2022_64", "linux": "linux_gcc_64", "mac": "clang_64"},
["./install-qt-commercial", "qt6", "680"],
"qt-unified-{}-x64-online.run --email ******** --pw ******** --root {} "
"--accept-licenses --accept-obligations "
"--confirm-command "
"--auto-answer OperationDoesNotExistError=Ignore,OverwriteTargetDirectory=No,"
"stopProcessesForUpdates=Cancel,installationErrorWithCancel=Cancel,installationErrorWithIgnore=Ignore,"
"AssociateCommonFiletypes=Yes,telemetry-question=No install qt.{}.{}.{}",
),
(
"install-qt-commercial desktop {} 6.8.1 " "--outputdir ./install-qt-commercial " "--user {} --password {}",
{"windows": "win64_msvc2022_64", "linux": "linux_gcc_64", "mac": "clang_64"},
["./install-qt-commercial", "qt6", "681"],
"qt-unified-{}-x64-online.run --email ******** --pw ******** --root {} "
"--accept-licenses --accept-obligations "
"--confirm-command "
"--auto-answer OperationDoesNotExistError=Ignore,OverwriteTargetDirectory=Yes,"
"stopProcessesForUpdates=Cancel,installationErrorWithCancel=Cancel,installationErrorWithIgnore=Ignore,"
"AssociateCommonFiletypes=Yes,telemetry-question=No install qt.{}.{}.{}",
),
],
)
def test_install_qt_commercial(
capsys, monkeypatch, cmd: str, arch_dict: dict[str, str], details: list[str], expected_command: str
) -> None:
"""Test commercial Qt installation command"""
# Mock subprocess.run instead of run_static_subprocess_dynamically
def mock_subprocess_run(*args, **kwargs):
# This will be called instead of the real subprocess.run
return CompletedProcess(args=args[0], returncode=0)
# Patch subprocess.run directly
monkeypatch.setattr("subprocess.run", mock_subprocess_run)
current_platform = sys.platform.lower()
arch = arch_dict[current_platform]
abs_out = Path(details[0]).absolute()
formatted_cmd = cmd.format(arch, "vofab76634@gholar.com", "WxK43TdWCTmxsrrpnsWbjPfPXVq3mtLK")
formatted_expected = expected_command.format(current_platform, abs_out, *details[1:], arch)
cli = Cli()
cli._setup_settings()
try:
cli.run(formatted_cmd.split())
except AttributeError:
out = " ".join(capsys.readouterr())
assert str(out).find(formatted_expected) >= 0
def modify_qt_config(content):
"""
Takes content of INI file as string and returns modified content
"""
lines = content.splitlines()
in_qt_commercial = False
modified = []
for line in lines:
# Check if we're entering qtcommercial section
if line.strip() == "[qtcommercial]":
in_qt_commercial = True
# If in qtcommercial section, look for the target line
if in_qt_commercial and "overwrite_target_directory : No" in line:
line = "overwrite_target_directory : Yes"
elif in_qt_commercial and "overwrite_target_directory : Yes" in line:
line = "overwrite_target_directory : No"
modified.append(line)
return "\n".join(modified)
script_dir = os.path.dirname(os.path.abspath(__file__))
config_path = os.path.join(script_dir, "../aqt/settings.ini")
with open(config_path, "r") as f:
content = f.read()
modified_content = modify_qt_config(content)
with open(config_path, "w") as f:
f.write(modified_content)

View File

@@ -1269,3 +1269,19 @@ def test_find_installed_qt_mingw_dir(expected_result: str, installed_files: List
actual_result = QtRepoProperty.find_installed_desktop_qt_dir(host, base_path, Version(qt_ver))
assert (actual_result.name if actual_result else None) == expected_result
# Test error cases
@pytest.mark.parametrize(
"args, expected_error",
[
(["list-qt-commercial", "--bad-flag"], "usage: aqt [-h] [-c CONFIG]"),
],
)
def test_list_qt_commercial_errors(capsys, args, expected_error):
"""Test error handling in list-qt-commercial command"""
cli = Cli()
with pytest.raises(SystemExit):
cli.run(args)
_, err = capsys.readouterr()
assert expected_error in err