From cbe159f38a39e8b94297480b0c2aec49df596bbb Mon Sep 17 00:00:00 2001 From: Alexandre Poumaroux <1204936+Kidev@users.noreply.github.com> Date: Tue, 28 Jan 2025 12:51:53 +0100 Subject: [PATCH] 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 --- .gitignore | 2 + aqt/commercial.py | 362 ++++++++++++++++++++++++++++++++++++++++++ aqt/helper.py | 168 +++++++++++++++++++- aqt/installer.py | 206 +++++++++++++++++++++++- aqt/settings.ini | 53 ++++--- tests/test_cli.py | 35 +++- tests/test_install.py | 147 ++++++++++++++--- tests/test_list.py | 16 ++ 8 files changed, 934 insertions(+), 55 deletions(-) create mode 100644 aqt/commercial.py diff --git a/.gitignore b/.gitignore index 264a1230..5df3509d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ Qt/ .eggs qtaccount.ini .pytest_cache +.run/ +.python-version diff --git a/aqt/commercial.py b/aqt/commercial.py new file mode 100644 index 00000000..e144f796 --- /dev/null +++ b/aqt/commercial.py @@ -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("") + xml_end = output_text.find("") + len("") + + 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}" diff --git a/aqt/helper.py b/aqt/helper.py index 2a790a0c..92976ac9 100644 --- a/aqt/helper.py +++ b/aqt/helper.py @@ -24,6 +24,8 @@ 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 @@ ) +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 @@ def load_settings(self, file: Optional[Union[str, TextIO]] = None) -> None: 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 @@ def min_module_size(self): """ 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 diff --git a/aqt/installer.py b/aqt/installer.py index f1aa7300..ccd515aa 100644 --- a/aqt/installer.py +++ b/aqt/installer.py @@ -42,6 +42,7 @@ 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 @@ 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 @@ def run_list_src_doc_examples(self, args: ListArgumentParser, cmd_type: str): ) 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 @@ def _format_aqt_version(self) -> str: 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 @@ 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: 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 @@ 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 {} @@ -798,13 +983,22 @@ 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_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 @@ def _make_list_tool_parser(self, subparsers: argparse._SubParsersAction): ) 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 close_worker_pool_on_exception(exception: BaseException): 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() diff --git a/aqt/settings.ini b/aqt/settings.ini index 3641e00e..75124334 100644 --- a/aqt/settings.ini +++ b/aqt/settings.ini @@ -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 \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index fe38e4f3..a6577f9d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,3 +1,4 @@ +import platform import re import sys from pathlib import Path @@ -15,8 +16,9 @@ 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 diff --git a/tests/test_install.py b/tests/test_install.py index f5becb6d..02c0d787 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -1676,30 +1676,30 @@ def mock_download_archive(url: str, out: Path, *args, **kwargs): assert result == 0 - # 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$" - ) + # 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$" + ) - assert expected_pattern.match(err) + assert expected_pattern.match(err) @pytest.mark.parametrize( @@ -2054,3 +2054,104 @@ def mock_get_url(url: str, *args, **kwargs) -> str: 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) diff --git a/tests/test_list.py b/tests/test_list.py index fa68acab..edc4f56f 100644 --- a/tests/test_list.py +++ b/tests/test_list.py @@ -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