diff --git a/.gitignore b/.gitignore index 264a1230..c4d53b86 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Qt/ .eggs qtaccount.ini .pytest_cache +.run/ diff --git a/aqt/commercial.py b/aqt/commercial.py new file mode 100644 index 00000000..83a59524 --- /dev/null +++ b/aqt/commercial.py @@ -0,0 +1,391 @@ +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 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) -> 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, + ] + + 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.""" + # 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", + "linux": "qt-unified-linux-x64-online.run", + } + + ALLOWED_AUTO_ANSWER_OPTIONS = { + "OperationDoesNotExistError": frozenset({"Abort", "Ignore"}), + "OverwriteTargetDirectory": frozenset({"Yes", "No"}), + "stopProcessesForUpdates": frozenset({"Retry", "Ignore", "Cancel"}), + "installationErrorWithCancel": frozenset({"Retry", "Ignore", "Cancel"}), + "installationErrorWithIgnore": frozenset({"Retry", "Ignore"}), + "AssociateCommonFiletypes": frozenset({"Yes", "No"}), + "telemetry-question": frozenset({"Yes", "No"}), + } + + UNATTENDED_FLAGS = frozenset( + [ + "--accept-licenses", + "--accept-obligations", + "--confirm-command", + ] + ) + + 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 = CommercialInstaller._get_os_name() + self._installer_filename = CommercialInstaller._get_qt_installer_name() + self.qt_account = CommercialInstaller._get_qt_account_path() + self.package_manager = QtPackageManager(self.arch, self.version, self.target, Settings.qt_installer_cache_path) + + @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(): + if value in CommercialInstaller.ALLOWED_AUTO_ANSWER_OPTIONS[key]: + 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(CommercialInstaller.UNATTENDED_FLAGS) + + 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." + ) + + 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, Settings.qt_installer_timeout) + + try: + 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.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() + + @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() -> 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[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..0b6914d5 100644 --- a/aqt/helper.py +++ b/aqt/helper.py @@ -344,14 +344,18 @@ 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: @@ -359,6 +363,12 @@ def __init__(self) -> None: self.configfile = os.path.join(os.path.dirname(__file__), "settings.ini") self.loggingconf = os.path.join(os.path.dirname(__file__), "logging.ini") + def _get_config(self) -> ConfigParser: + """Safe getter for config that ensures it's initialized.""" + self._initialize() + assert self.config is not None # This helps mypy understand config won't be None + return self.config + def load_settings(self, file: Optional[Union[str, TextIO]] = None) -> None: if self.config is None: return @@ -473,6 +483,59 @@ 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_cache_path(self) -> str: + """Path for Qt installer cache.""" + return self._get_config().get( + "qtcommercial", "cache_path", fallback=str(Path.home() / ".cache" / "aqt" / "qtcommercial") + ) + + @property + def qt_installer_unattended(self) -> bool: + """Control whether to use unattended installation flags.""" + return self._get_config().getboolean("qtcommercial", "unattended", fallback=True) + Settings = SettingsClass() diff --git a/aqt/installer.py b/aqt/installer.py index f1aa7300..dae3bfc5 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, @@ -124,9 +125,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 +669,42 @@ 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: + # When override is used, we only need minimal parameters + 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, + ) + else: + # Original validation and installer creation + if not all([args.target, args.arch, args.version]): + raise CliInputError("target, arch, and version are required when not using --override") + + 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, + ) + + try: + commercial_installer.install() + 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 +715,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 +798,51 @@ 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): + 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 _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 +857,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,12 +892,20 @@ def make_parser_list_sde(cmd: str, desc: str, cmd_type: str): if cmd_type != "src": parser.add_argument("-m", "--modules", action="store_true", help="Print list of available modules") + # Create install command parsers make_parser_it("install-qt", "Install Qt.", self._set_install_qt_parser, argparse.RawTextHelpFormatter) make_parser_it("install-tool", "Install tools.", self._set_install_tool_parser, None) + make_parser_it( + "install-qt-commercial", + "Install Qt commercial.", + self._set_install_qt_commercial_parser, + argparse.RawTextHelpFormatter, + ) make_parser_sde("install-doc", "Install documentation.", self.run_install_doc, False) make_parser_sde("install-example", "Install examples.", self.run_install_example, False) make_parser_sde("install-src", "Install source.", self.run_install_src, True, is_add_modules=False) + # Create list command parsers self._make_list_qt_parser(subparsers) self._make_list_tool_parser(subparsers) make_parser_list_sde("list-doc", "List documentation archives available (use with install-doc)", "doc") @@ -948,14 +1050,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 +1337,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 +1350,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..13e75d75 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 diff --git a/tests/test_cli.py b/tests/test_cli.py index fe38e4f3..517d00e2 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,7 +16,8 @@ def expected_help(actual, prefix=None): expected = ( "usage: aqt [-h] [-c CONFIG]\n" - " {install-qt,install-tool,install-doc,install-example,install-src," + " {install-qt,install-tool,install-qt-commercial,install-doc,install-example," + "install-src," "list-qt,list-tool,list-doc,list-example,list-src,help,version}\n" " ...\n" "\n" @@ -32,7 +34,8 @@ def expected_help(actual, prefix=None): " install-* subcommands are commands that install components\n" " list-* subcommands are commands that show available components\n" "\n" - " {install-qt,install-tool,install-doc,install-example,install-src,list-qt," + " {install-qt,install-tool,install-qt-commercial,install-doc,install-example," + "install-src,list-qt," "list-tool,list-doc,list-example,list-src,help,version}\n" " Please refer to each help message by using '--help' " "with each subcommand\n", @@ -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..0e9b23e3 100644 --- a/tests/test_install.py +++ b/tests/test_install.py @@ -11,6 +11,7 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path +from subprocess import CompletedProcess from tempfile import TemporaryDirectory from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple @@ -1676,30 +1677,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 +2055,49 @@ def mock_get_url(url: str, *args, **kwargs) -> str: sys.stderr.write(err) assert expect_out.match(err), err + + +@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.{}.{}.{}", + ), + ], +) +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""" + + def mock_run(*args, **kwargs) -> CompletedProcess: + return None + + # Use monkeypatch to replace subprocess.run + monkeypatch.setattr(subprocess, "run", mock_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