diff --git a/aqt/commercial.py b/aqt/commercial.py index 50557d04..738748d0 100644 --- a/aqt/commercial.py +++ b/aqt/commercial.py @@ -1,17 +1,173 @@ +import json import os import platform import subprocess import tempfile +from dataclasses import dataclass from logging import Logger, getLogger from pathlib import Path -from typing import Optional +from typing import List, Optional import requests +from defusedxml import ElementTree +from aqt.helper import Settings 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, temp_dir: str): + self.arch = arch + self.version = version + self.target = target + self.temp_dir = temp_dir + self.cache_dir = self._get_cache_dir() + self.packages: List[QtPackageInfo] = [] + + def _get_cache_dir(self) -> Path: + """Create and return cache directory path.""" + base_cache = Path.home() / ".cache" / "aqt" + cache_path = base_cache / self.target / self.arch / str(self.version) + cache_path.mkdir(parents=True, exist_ok=True) + return cache_path + + def _get_cache_file(self) -> Path: + """Get the cache file path.""" + return self.cache_dir / "packages.json" + + def _save_to_cache(self): + """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): + """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, + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + + # Extract the XML portion from the output + xml_start = result.stdout.find("") + xml_end = result.stdout.find("") + len("") + + if xml_start != -1 and xml_end != -1: + xml_content = result.stdout[xml_start:xml_end] + self._parse_packages_xml(xml_content) + self._save_to_cache() + else: + raise RuntimeError("Failed to find package information in installer output") + + except subprocess.CalledProcessError as e: + raise RuntimeError(f"Failed to gather packages: {e}") + + def get_install_command(self, modules: Optional[List[str]], install_path: str) -> List[str]: + """Generate installation command based on requested modules.""" + version_str = self._get_version_string() + + # If 'all' is in modules, use the -full package + if modules and "all" in modules: + package_name = f"{self._get_base_package_name()}.{self.arch}-full" + else: + # Base package name + package_name = f"{self._get_base_package_name()}.{self.arch}" + + cmd = [ + "--root", + install_path, + "--accept-licenses", + "--accept-obligations", + "--confirm-command", + "--auto-answer", + "OperationDoesNotExistError=Ignore,OverwriteTargetDirectory=No," + "stopProcessesForUpdates=Cancel,installationErrorWithCancel=Cancel," + "installationErrorWithIgnore=Ignore,AssociateCommonFiletypes=Yes," + "telemetry-question=No", + "install", + package_name, + ] + + # Add individual modules if specified and not using 'all' + if modules and "all" not in modules: + for module in modules: + module_pkg = f"{self._get_base_package_name()}.addons.{module}" + if any(p.name == module_pkg for p in self.packages): + cmd.append(module_pkg) + + return cmd + + class CommercialInstaller: + """Qt Commercial installer that handles module installation and package management.""" + ALLOWED_INSTALLERS = { "windows": "qt-unified-windows-x64-online.exe", "mac": "qt-unified-macOS-x64-online.dmg", @@ -28,6 +184,14 @@ class CommercialInstaller: "telemetry-question": frozenset({"Yes", "No"}), } + UNATTENDED_FLAGS = frozenset( + [ + "--accept-licenses", + "--accept-obligations", + "--confirm-command", + ] + ) + def __init__( self, target: str, @@ -37,16 +201,10 @@ def __init__( password: Optional[str] = None, output_dir: Optional[str] = None, logger: Optional[Logger] = None, - timeout: Optional[float] = None, base_url: str = "https://download.qt.io", - operation_does_not_exist_error="Ignore", - overwrite_target_dir: str = "Yes", - stop_processes_for_updates: str = "Cancel", - installation_error_with_cancel: str = "Cancel", - installation_error_with_ignore: str = "Ignore", - associate_common_filetypes: str = "Yes", - telemetry: str = "No", override: Optional[list[str]] = None, + modules: Optional[List[str]] = None, + no_unattended: bool = False, ): self.override = override self.target = target @@ -56,152 +214,184 @@ def __init__( self.password = password self.output_dir = output_dir self.logger = logger or getLogger(__name__) - self.timeout = int(timeout) if timeout else 3600 self.base_url = base_url - - # Store auto-answer options - self.operation_does_not_exist_error = operation_does_not_exist_error - self.overwrite_target_dir = overwrite_target_dir - self.stop_processes_for_updates = stop_processes_for_updates - self.installation_error_with_cancel = installation_error_with_cancel - self.installation_error_with_ignore = installation_error_with_ignore - self.associate_common_filetypes = associate_common_filetypes - self.telemetry = telemetry + self.modules = modules + self.no_unattended = no_unattended # Set OS-specific properties - self.os_name = self._get_os_name() - self._installer_filename = self.ALLOWED_INSTALLERS[self.os_name] - self.qt_account = self._get_qt_account_path() - - def _get_os_name(self) -> str: - system = platform.system() - if system == "Darwin": - return "mac" - elif system == "Linux": - return "linux" - elif system == "Windows": - return "windows" - else: - raise ValueError(f"Unsupported operating system: {system}") + self.os_name = CommercialInstaller._get_os_name() + self._installer_filename = CommercialInstaller._get_qt_installer_name() + self.qt_account = CommercialInstaller._get_qt_account_path() + self.package_manager = None - def _get_qt_account_path(self) -> Path: - if self.os_name == "windows": - appdata = os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")) - return Path(appdata) / "Qt" / "qtaccount.ini" - elif self.os_name == "mac": - return Path.home() / "Library" / "Application Support" / "Qt" / "qtaccount.ini" - else: # Linux - return Path.home() / ".local" / "share" / "Qt" / "qtaccount.ini" + @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, + } - def _download_installer(self, target_path: Path) -> None: - url = f"{self.base_url}/official_releases/online_installers/{self._installer_filename}" - try: - response = requests.get(url, stream=True, timeout=self.timeout) - response.raise_for_status() + answers = [] + for key, value in settings_map.items(): + if value in CommercialInstaller.ALLOWED_AUTO_ANSWER_OPTIONS[key]: + answers.append(f"{key}={value}") - 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}") + return ",".join(answers) - 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}" + @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] - def _get_install_command(self, installer_path: Path) -> list[str]: - """Build the installation command array""" - # Start with installer path (will be replaced with absolute path in _exec_qt_installer) - cmd = [str(installer_path)] + # Add unattended flags unless explicitly disabled + if not no_unattended: + cmd.extend(CommercialInstaller.UNATTENDED_FLAGS) - # When override is specified, only use the installer path and the override parameters - if self.override: - return cmd + self.override + if override: + # When using override, still include unattended flags unless disabled + cmd.extend(override) + return cmd # Add authentication if provided - if self.username and self.password: - cmd.extend(["--email", self.username, "--pw", self.password]) + if username and password: + cmd.extend(["--email", username, "--pw", password]) # Add output directory if specified - if self.output_dir: - output_path = Path(self.output_dir).resolve() - output_path.mkdir(parents=True, exist_ok=True) - cmd.extend(["--root", str(output_path)]) - - # Add standard installation options - cmd.extend( - [ - "--accept-licenses", - "--accept-obligations", - "--confirm-command", - ] - ) - - # Build auto-answer options - auto_answers = [] - auto_answer_map = { - "OperationDoesNotExistError": self.operation_does_not_exist_error, - "OverwriteTargetDirectory": self.overwrite_target_dir, - "stopProcessesForUpdates": self.stop_processes_for_updates, - "installationErrorWithCancel": self.installation_error_with_cancel, - "installationErrorWithIgnore": self.installation_error_with_ignore, - "AssociateCommonFiletypes": self.associate_common_filetypes, - "telemetry-question": self.telemetry, - } - - for key, value in auto_answer_map.items(): - if key in self.ALLOWED_AUTO_ANSWER_OPTIONS and value in self.ALLOWED_AUTO_ANSWER_OPTIONS[key]: - auto_answers.append(f"{key}={value}") - - if not auto_answers: - raise ValueError("No valid auto-answer options provided") - - cmd.extend(["--auto-answer", ",".join(auto_answers)]) + if output_dir: + cmd.extend(["--root", str(Path(output_dir).resolve())]) - # Add install command and package - cmd.extend(["install", self._get_package_name()]) + # 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 os.environ.get("QT_INSTALLER_JWT_TOKEN") == "" + 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." ) + cache_path = Path(Settings.qt_installer_cache_path) + cache_path.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="qt_install_") as temp_dir: temp_path = Path(temp_dir) - installer_path = temp_path / self._installer_filename + self.logger.info(f"Downloading Qt installer to {installer_path}") - self._download_installer(installer_path) + installer_url = f"{self.base_url}/official_releases/online_installers/{self._installer_filename}" + self.download_installer(installer_path, Settings.qt_installer_timeout) try: - cmd = self._get_install_command(installer_path) - safe_cmd = cmd.copy() - if "--pw" in safe_cmd: - pw_index = safe_cmd.index("--pw") - if len(safe_cmd) > pw_index + 1: - safe_cmd[pw_index + 1] = "********" - if "--email" in safe_cmd: - email_index = safe_cmd.index("--email") - if len(safe_cmd) > email_index + 1: - safe_cmd[email_index + 1] = "********" - self.logger.info(f"Running: {' '.join(safe_cmd)}") - - subprocess.run(cmd, shell=False, check=True, cwd=temp_dir) - - except subprocess.CalledProcessError as e: - self.logger.error(f"Installation failed with exit code {e.returncode}") - except subprocess.TimeoutExpired: - self.logger.error("Installation timed out") + cmd = None + 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 = QtPackageManager( + arch=self.arch, version=self.version, target=self.target, temp_dir=str(cache_path) + ) + self.package_manager.gather_packages(str(installer_path)) + + base_cmd = self.build_command( + str(installer_path), + 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, self.output_dir or os.getcwd()) + + self.logger.info(f"Running: {' '.join(cmd)}") + + try: + subprocess.run(cmd, shell=False, check=True, cwd=temp_dir, timeout=Settings.qt_installer_timeout) + except subprocess.TimeoutExpired: + self.logger.error(f"Installation timed out after {Settings.qt_installer_timeout} seconds") + raise + except subprocess.CalledProcessError as e: + self.logger.error(f"Installation failed with exit code {e.returncode}") + raise + + self.logger.info("Qt installation completed successfully") + finally: if installer_path.exists(): installer_path.unlink() - self.logger.info("Qt installation completed successfully") + + @staticmethod + def _get_os_name() -> str: + system = platform.system() + if system == "Darwin": + return "mac" + if system == "Linux": + return "linux" + if system == "Windows": + return "windows" + raise ValueError(f"Unsupported operating system: {system}") + + @staticmethod + def _get_qt_local_folder_path() -> Path: + os_name = CommercialInstaller._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" + + @staticmethod + def _get_qt_account_path() -> Path: + return CommercialInstaller._get_qt_local_folder_path() / "qtaccount.ini" + + @staticmethod + def _get_qt_installer_name() -> Path: + 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[CommercialInstaller._get_os_name()] + + @staticmethod + def _get_qt_installer_path() -> Path: + return CommercialInstaller._get_qt_local_folder_path() / CommercialInstaller._get_qt_installer_name() + + 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..a69821fd 100644 --- a/aqt/helper.py +++ b/aqt/helper.py @@ -473,6 +473,52 @@ 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): + """Timeout for Qt commercial installer operations in seconds.""" + return self.config.getfloat("qtcommercial", "installer_timeout", fallback=3600) + + @property + def qt_installer_operationdoesnotexisterror(self): + """Handle OperationDoesNotExistError in Qt installer.""" + return self.config.get("qtcommercial", "operation_does_not_exist_error", fallback="Ignore") + + @property + def qt_installer_overwritetargetdirectory(self): + """Handle overwriting target directory in Qt installer.""" + return self.config.get("qtcommercial", "overwrite_target_directory", fallback="No") + + @property + def qt_installer_stopprocessesforupdates(self): + """Handle stopping processes for updates in Qt installer.""" + return self.config.get("qtcommercial", "stop_processes_for_updates", fallback="Cancel") + + @property + def qt_installer_installationerrorwithcancel(self): + """Handle installation errors with cancel option in Qt installer.""" + return self.config.get("qtcommercial", "installation_error_with_cancel", fallback="Cancel") + + @property + def qt_installer_installationerrorwithignore(self): + """Handle installation errors with ignore option in Qt installer.""" + return self.config.get("qtcommercial", "installation_error_with_ignore", fallback="Ignore") + + @property + def qt_installer_associatecommonfiletypes(self): + """Handle file type associations in Qt installer.""" + return self.config.get("qtcommercial", "associate_common_filetypes", fallback="Yes") + + @property + def qt_installer_telemetry(self): + """Handle telemetry settings in Qt installer.""" + return self.config.get("qtcommercial", "telemetry", fallback="No") + + @property + def qt_installer_cache_path(self): + """Path for Qt installer cache.""" + return self.config.get("qtcommercial", "cache_path", fallback=str(Path.home() / ".cache" / "aqt" / "qtcommercial")) + Settings = SettingsClass() diff --git a/aqt/installer.py b/aqt/installer.py index 3d918f8a..58821bdd 100644 --- a/aqt/installer.py +++ b/aqt/installer.py @@ -680,7 +680,6 @@ def run_install_qt_commercial(self, args: InstallArgParser) -> None: arch="", version=None, logger=self.logger, - timeout=args.timeout if args.timeout is not None else Settings.response_timeout, base_url=args.base if args.base is not None else Settings.baseurl, override=args.override, ) @@ -697,15 +696,7 @@ def run_install_qt_commercial(self, args: InstallArgParser) -> None: password=args.password, output_dir=args.outputdir, logger=self.logger, - timeout=args.timeout if args.timeout is not None else Settings.response_timeout, base_url=args.base if args.base is not None else Settings.baseurl, - operation_does_not_exist_error=args.operation_does_not_exist_error, - overwrite_target_dir=args.overwrite_target_dir, - stop_processes_for_updates=args.stop_processes_for_updates, - installation_error_with_cancel=args.installation_error_with_cancel, - installation_error_with_ignore=args.installation_error_with_ignore, - associate_common_filetypes=args.associate_common_filetypes, - telemetry=args.telemetry, ) try: @@ -846,46 +837,9 @@ def __call__(self, parser, namespace, values, option_string=None): help="Qt account password", ) install_qt_commercial_parser.add_argument( - "--operation_does_not_exist_error", - choices=["Abort", "Ignore"], - default="Ignore", - help="OperationDoesNotExistError: Abort, Ignore. Default: Ignore", - ) - install_qt_commercial_parser.add_argument( - "--overwrite_target_dir", - choices=["Yes", "No"], - default="No", - help="OverwriteTargetDirectory: Yes, No. Default: No", - ) - install_qt_commercial_parser.add_argument( - "--stop_processes_for_updates", - choices=["Retry", "Ignore", "Cancel"], - default="Cancel", - help="stopProcessesForUpdates: Retry, Ignore, Cancel. Default: Cancel", - ) - install_qt_commercial_parser.add_argument( - "--installation_error_with_cancel", - choices=["Retry", "Ignore", "Cancel"], - default="Cancel", - help="installationErrorWithCancel: Retry, Ignore, Cancel. Default: Cancel", - ) - install_qt_commercial_parser.add_argument( - "--installation_error_with_ignore", - choices=["Retry", "Ignore"], - default="Ignore", - help="installationErrorWithIgnore: Retry, Ignore. Default: Ignore", - ) - install_qt_commercial_parser.add_argument( - "--associate_common_filetypes", - choices=["Yes", "No"], - default="Yes", - help="AssociateCommonFiletypes: Yes, No. Default: Yes", - ) - install_qt_commercial_parser.add_argument( - "--telemetry", - choices=["Yes", "No"], - default="No", - help="telemetry-question: Yes, No. Default: No", + "--modules", + nargs="?", + help="Add modules", ) self._set_common_options(install_qt_commercial_parser) diff --git a/aqt/settings.ini b/aqt/settings.ini index 3641e00e..1d0a3b9b 100644 --- a/aqt/settings.ini +++ b/aqt/settings.ini @@ -19,6 +19,31 @@ max_retries_to_retrieve_hash: 5 hash_algorithm: sha256 INSECURE_NOT_FOR_PRODUCTION_ignore_hash: False +[qtcommercial] +# If False, it will require user inputs +# If True, it will append --accept-licenses --accept-obligations --confirm-command to the command +# Even if `--override` is provided +unattended = True + +# Timeout for Qt installer operations (in seconds) +installer_timeout = 30 + +# Auto-answer settings for Qt installer +# Those will be translated into flags and passed to the installer automatically +# Even if `--override` is provided +operation_does_not_exist_error = Ignore +overwrite_target_directory = No +stop_processes_for_updates = Cancel +installation_error_with_cancel = Cancel +installation_error_with_ignore = Ignore +associate_common_filetypes = Yes +telemetry = No + +# Cache path for Qt installer files +# This entry is absent from shared settings.ini, and auto updates on init if absent to be the most relevant folder possible given the OS +#cache_path = ~/.cache/aqt + + [mirrors] trusted_mirrors: https://download.qt.io