############################################################################# ## ## Copyright (C) 2021 The Qt Company Ltd. ## Contact: https://www.qt.io/licensing/ ## ## This file is part of the release tools of the Qt Toolkit. ## ## $QT_BEGIN_LICENSE:GPL-EXCEPT$ ## Commercial License Usage ## Licensees holding valid commercial Qt licenses may use this file in ## accordance with the commercial license agreement provided with the ## Software or, alternatively, in accordance with the terms contained in ## a written agreement between you and The Qt Company. For licensing terms ## and conditions see https://www.qt.io/terms-conditions. For further ## information use the contact form at https://www.qt.io/contact-us. ## ## GNU General Public License Usage ## Alternatively, this file may be used under the terms of the GNU ## General Public License version 3 as published by the Free Software ## Foundation with exceptions as appearing in the file LICENSE.GPL3-EXCEPT ## included in the packaging of this file. Please review the following ## information to ensure the GNU General Public License requirements will ## be met: https://www.gnu.org/licenses/gpl-3.0.html. ## ## $QT_END_LICENSE$ ## ############################################################################# from conans import ConanFile, tools, Options from conans.errors import ConanInvalidConfiguration import os import re import json import subprocess from functools import lru_cache from pathlib import Path from typing import Dict, List, Any, Optional class QtConanError(Exception): pass class QtConfigureOption(object): def __init__(self, name: str, type: str, values: List[Any], default: Any): self.name = name self.type = type self.conan_option_name = self.convert_to_conan_option_name(name) if type == "enum" and set(values) == {"yes", "no"}: self._binary_option = True # matches to Conan option "yes"|"no" values = [] self._prefix = "-" self._value_delim = "" elif "string" in type.lower() or type in ["enum", "cxxstd", "coverage", "sanitize"]: # these options have a value, e.g. # --zlib=qt (enum type) # --c++std=c++17 (cxxstd type) # --prefix=/foo self._binary_option = False self._prefix = "--" self._value_delim = "=" # exception to the rule if name == "qt-host-path": self._prefix = "-" self._value_delim = " " else: # e.g. -debug (void type) self._binary_option = True self._prefix = "-" self._value_delim = "" if not self._binary_option and not values: self.possible_values = ["ANY"] elif type == "addString": # -make=libs -make=examples <-> -o make="libs;examples" i.e. the possible values # can be randomly selected values in semicolon separated list -> "ANY" self.possible_values = ["ANY"] else: self.possible_values = values if self._binary_option and self.possible_values: raise QtConanError(f"A binary option: '{name}' can not contain values: {self.possible_values}") self.default = default @property def binary_option(self) -> bool: return self._binary_option @property def incremental_option(self) -> bool: return self.type == "addString" @property def prefix(self) -> str: return self._prefix @property def value_delim(self) -> str: return self._value_delim def convert_to_conan_option_name(self, qt_configure_option: str) -> str: # e.g. '-c++std' -> '-cxxstd' or '-cmake-generator' -> 'cmake_generator' return qt_configure_option.lstrip("-").replace("-", "_").replace("+", "x") def get_conan_option_values(self) -> Any: # The 'None' is added as a possible value. For Conan this means it is not mandatory to pass # this option for the build. if self._binary_option: return ["yes", "no", None] if self.possible_values == ["ANY"]: # For 'ANY' value it can not be a List type for Conan return "ANY" return self.possible_values + [None] # type: ignore def get_default_conan_option_value(self) -> Any: return self.default class QtOptionParser: def __init__(self) -> None: self.options: List[QtConfigureOption] = [] self.load_configure_options() self.extra_options: Dict[str, Any] = {"extra_cmake_args": "ANY"} self.extra_options_default_values = {"extra_cmake_args": None} def load_configure_options(self) -> None: """Read the configure options and features dynamically via configure(.bat). There are two contexts where the ConanFile is initialized: - 'conan export' i.e. when the conan package is being created from sources (.git) - inside conan's cache when invoking: 'conan install, conan info, conan inspect, ..' """ print("QtOptionParser: load configure options ..") recipe_folder = Path(__file__).parent.resolve() configure_options = Path(recipe_folder) / "configure_options.json" configure_features = Path(recipe_folder) / "configure_features.txt" if not configure_options.exists() or not configure_features.exists(): # This is when the 'conan export' is called script = Path("configure.bat") if tools.os_info.is_windows else Path("configure") root_path = recipe_folder configure = Path(root_path / script).resolve() if not configure.exists(): root_path = Path(root_path / ".." / "export_source").resolve() if root_path.exists(): configure = Path(root_path / script).resolve(strict=True) else: raise QtConanError(f"Unable to locate 'configure(.bat)' from current context: {recipe_folder}") self.write_configure_options(configure, output_file=configure_options) self.write_configure_features(configure, output_file=configure_features) opt = self.read_configure_options(configure_options) self.set_configure_options(opt["options"]) features = self.read_configure_features(configure_features) self.set_features(feature_name_prefix="feature-", features=features) def write_configure_options(self, configure: Path, output_file: Path) -> None: print(f"QtOptionParser: writing Qt configure options to: {output_file}") cmd = [str(configure), "-write-options-for-conan", str(output_file)] subprocess.run(cmd, check=True, timeout=60 * 2) def read_configure_options(self, input_file: Path) -> Dict[str, Any]: print(f"QtOptionParser: reading Qt configure options from: {input_file}") with open(str(input_file)) as f: return json.load(f) def write_configure_features(self, configure: Path, output_file: Path) -> None: print(f"QtOptionParser: writing Qt configure features to: {output_file}") cmd = [str(configure), "-list-features"] with open(output_file, "w") as f: subprocess.run(cmd, encoding="utf-8", check=True, timeout=60 * 2, stderr=subprocess.STDOUT, stdout=f) def read_configure_features(self, input_file: Path) -> List[str]: print(f"QtOptionParser: reading Qt configure features from: {input_file}") with open(str(input_file)) as f: return f.readlines() def set_configure_options(self, configure_options: Dict[str, Any]) -> None: for option_name, field in configure_options.items(): option_type = field.get("type") values: List[str] = field.get("values", []) # For the moment all Options will get 'None' as the default value default = None if not option_type: raise QtConanError( "Qt 'configure(.bat) -write-options-for-conan' produced output that is " f"missing 'type'. Unable to set options dynamically. Item: {option_name}" ) if not isinstance(values, list): raise QtConanError(f"The 'values' field is not a list: {option_name}") if option_type == "enum" and not values: raise QtConanError(f"The enum values are missing for: {option_name}") opt = QtConfigureOption(name=option_name, type=option_type, values=values, default=default) self.options.append(opt) def set_features(self, feature_name_prefix: str, features: List[str]) -> None: for line in features: feature_name = self.parse_feature(line) if feature_name: opt = QtConfigureOption(name=feature_name_prefix + feature_name, type="void", values=[], default=None) self.options.append(opt) def parse_feature(self, feature_line: str) -> Optional[str]: parts = feature_line.split() # e.g. 'itemmodel ................ ItemViews: Provides the item model for item views' if not len(parts) >= 3: return None if not parts[1].startswith("."): return None return parts[0] def get_qt_conan_options(self) -> Dict[str, Any]: # obtain all the possible configure(.bat) options and map those to # Conan options for the recipe opt: Dict = {} for qt_option in self.options: opt[qt_option.conan_option_name] = qt_option.get_conan_option_values() opt.update(self.extra_options) return opt def get_default_qt_conan_options(self) -> Dict[str, Any]: # set the default option values for each option in case the user or CI does not pass them opt: Dict = {} for qt_option in self.options: opt[qt_option.conan_option_name] = qt_option.get_default_conan_option_value() opt.update(self.extra_options_default_values) return opt def is_used_option(self, conan_option_value: str) -> bool: # conan install ... -o release=no -> configure(.bat) # conan install ... -> configure(.bat) # conan install ... -o release=yes -> configure(.bat) -release if not conan_option_value or conan_option_value == "None" or conan_option_value == "no": # Conan seems to convert None to literal 'None'? return False return True def convert_conan_option_to_qt_option(self, name: str, value: Any) -> str: ret: str = "" def _find_qt_option(conan_option_name: str) -> QtConfigureOption: for qt_opt in self.options: if conan_option_name == qt_opt.conan_option_name: return qt_opt else: raise QtConanError(f"Could not find a matching Qt configure option for: {conan_option_name}") def _is_excluded_from_configure() -> bool: # extra options are not Qt configure(.bat) options but those exist as conan recipe options which # are treated outside Qt's configure(.bat) if name in self.extra_options.keys(): return True return False if self.is_used_option(value) and not _is_excluded_from_configure(): qt_option = _find_qt_option(name) if qt_option.incremental_option: # e.g. -make=libs -make=examples <-> -o make=libs;examples;foo;bar _opt = qt_option.prefix + qt_option.name + qt_option.value_delim ret = " ".join(_opt + item.strip() for item in value.split(";") if item.strip()) else: ret = qt_option.prefix + qt_option.name if not qt_option.binary_option: ret += qt_option.value_delim + value return ret def convert_conan_options_to_qt_options(self, conan_options: Options) -> List[str]: qt_options: List[str] = [] def _option_enabled(options: Dict[str, Any], opt: str) -> bool: return opt in options and options[opt] == "yes" def _option_disabled(options: Dict[str, Any], opt: str) -> bool: return opt in options and options[opt] == "no" def _filter_overlapping_options(options: Dict[str, Any]) -> None: if _option_enabled(options, "shared") or _option_disabled(options, "static"): del _options["static"] # should result only into "-shared" if _option_enabled(options, "static") or _option_disabled(options, "shared"): del _options["shared"] # should result only into "-static" _options = {key: value for key, value in conan_options.items()} _filter_overlapping_options(_options) for option_name, option_value in _options.items(): qt_option = self.convert_conan_option_to_qt_option(name=option_name, value=option_value) if not qt_option: continue qt_options.append(qt_option) return qt_options def get_extra_cmake_args_for_configure(self, conan_options: Options) -> List[Optional[str]]: ret: List[Optional[str]] = [] for option_name, option_value in conan_options.items(): if option_name == "extra_cmake_args" and self.is_used_option(option_value): ret = [ret for ret in option_value.strip(r" '\"").split()] return ret def _build_qtbase(conan_file: ConanFile): # we call the Qt's configure(.bat) directly script = Path("configure.bat") if tools.os_info.is_windows else Path("configure") configure = Path(Path(conan_file.build_folder).resolve() / script).resolve(strict=True) # convert the Conan options to Qt configure(.bat) arguments qt_configure_options = conan_file._qt_option_parser.convert_conan_options_to_qt_options(conan_file.options) cmd = " ".join([str(configure), " ".join(qt_configure_options), "-prefix", conan_file.package_folder]) cmake_args = conan_file._qt_option_parser.get_extra_cmake_args_for_configure(conan_file.options) if cmake_args: cmd += f" -- {' '.join(cmake_args)}" conan_file.output.info(f"Calling: {cmd}") conan_file.run(cmd) cmd = " ".join(["cmake", "--build", ".", "--parallel"]) conan_file.output.info(f"Calling: {cmd}") conan_file.run(cmd) @lru_cache(maxsize=8) def _parse_qt_version_by_key(key: str) -> str: with open(Path(Path(__file__).parent.resolve() / ".cmake.conf")) as f: ret = [m.group(1) for m in [re.search(r"{0} .*\"(.*)\"".format(key), f.read())] if m] return ret.pop() if ret else "" class QtBase(ConanFile): name = "qtbase" license = "GPL-3.0+, Commercial Qt License Agreement" author = "The Qt Company " url = "https://code.qt.io/cgit/qt/qtbase.git/" description = "Qt6 core framework libraries and tools." topics = ("qt", "qt6") settings = "os", "compiler", "arch", "build_type" _qt_option_parser = QtOptionParser() options = _qt_option_parser.get_qt_conan_options() default_options = _qt_option_parser.get_default_qt_conan_options() exports = "configure_options.json", "configure_features.txt", ".cmake.conf" exports_sources = "*", "!conan*.*" # use commit ID as the RREV (recipe revision) if this is exported from .git repository revision_mode = "scm" if Path(Path(__file__).parent.resolve() / ".git").exists() else "hash" python_requires = f"qt-conan-common/{_parse_qt_version_by_key('QT_REPO_MODULE_VERSION')[0:3]}@qt/everywhere" def set_version(self): # Executed during "conan export" i.e. in source tree _ver = _parse_qt_version_by_key("QT_REPO_MODULE_VERSION") _prerelease = _parse_qt_version_by_key("QT_REPO_MODULE_PRERELEASE_VERSION_SEGMENT") self.version = _ver + "-" + _prerelease if _prerelease else _ver def source(self): # sources are installed next to recipe, no need to clone sources here pass def configure(self): if self.settings.compiler == "gcc" and tools.Version(self.settings.compiler.version) < "8": raise ConanInvalidConfiguration("Qt6 does not support GCC before 8") def _set_default_if_not_set(option_name: str, option_value: str) -> None: # let it fail if option name does not exist, it means the recipe is not up to date value = getattr(self.options, option_name) if not value or value == "None": setattr(self.options, option_name, option_value) default_all_os_options = ["release", "shared", "gui", "widgets", "accessibility", "system_proxies", "ico"] for item in default_all_os_options: _set_default_if_not_set(item, "yes") if self.settings.os == "Windows": self.options.debug_and_release = "yes" elif self.settings.os == "Linux": pass elif self.settings.os == "Macos": self.options.debug_and_release = "yes" self.options.framework = "yes" if self.options.get_safe("debug_and_release", default="no") == "yes": # Qt build system will build both debug and release binaries del self.settings.build_type # Map Conan build_type to Qt configure(.bat) options if self.settings.get_safe("build_type", default="None") == "Release": self.options.release = "yes" self.options.debug = "no" elif self.settings.get_safe("build_type", default="None") == "Debug": self.options.release = "no" self.options.debug = "yes" elif self.settings.get_safe("build_type", default="None") == "MinSizeRel": self.options.optimize_size = "yes" elif self.settings.get_safe("build_type", default="None") == "RelWithDebInfo": self.options.force_debug_info = "yes" def build(self): self.python_requires["qt-conan-common"].module.build_env_wrap(self, _build_qtbase) def package(self): cmd = ["cmake", "--install", "."] self.run(" ".join(cmd)) with open(os.path.join(self.package_folder, "bin", "qt.conf"), "w") as f: f.write("[Paths]\nPrefix = ..\n") def package_info(self): self.python_requires["qt-conan-common"].module.package_info(self) def package_id(self): # https://docs.conan.io/en/latest/creating_packages/define_abi_compatibility.html self.info.requires.package_revision_mode() # Remove those configure(.bat) options which should not affect package_id. # These point to local file system paths and in order to re-use pre-built binaries (by Qt CI) # by others these should not affect the 'package_id' as those probably differ on each machine rm_list = [ "sdk", "android_sdk_path", "android_ndk_path", "android_ndk_platform", "android_abis", "android_javac_target", "android_javac_source", "qpa", "translationsdir", ] for item in rm_list: if item in self.info.options: delattr(self.info.options, item) def deploy(self): self.copy("*") # copy from current package self.copy_deps("*") # copy from dependencies