diff --git a/conan/tools/build/__init__.py b/conan/tools/build/__init__.py index f93b3a4dda9..c7a2e667b93 100644 --- a/conan/tools/build/__init__.py +++ b/conan/tools/build/__init__.py @@ -1,2 +1,4 @@ from conan.tools.build.cpu import build_jobs from conan.tools.build.cross_building import cross_building, can_run +from conan.tools.build.cppstd import check_min_cppstd, valid_min_cppstd, default_cppstd, \ + supported_cppstd diff --git a/conan/tools/build/cppstd.py b/conan/tools/build/cppstd.py new file mode 100644 index 00000000000..339a89cc23c --- /dev/null +++ b/conan/tools/build/cppstd.py @@ -0,0 +1,189 @@ +from conans.errors import ConanInvalidConfiguration, ConanException +from conans.model.version import Version + + +def check_min_cppstd(conanfile, cppstd, gnu_extensions=False): + """ Check if current cppstd fits the minimal version required. + + In case the current cppstd doesn't fit the minimal version required + by cppstd, a ConanInvalidConfiguration exception will be raised. + + 1. If settings.compiler.cppstd, the tool will use settings.compiler.cppstd to compare + 2. It not settings.compiler.cppstd, the tool will use compiler to compare (reading the + default from cppstd_default) + 3. If not settings.compiler is present (not declared in settings) will raise because it + cannot compare. + 4. If can not detect the default cppstd for settings.compiler, a exception will be raised. + + :param conanfile: The current recipe object. Always use ``self``. + :param cppstd: Minimal cppstd version required + :param gnu_extensions: GNU extension is required (e.g gnu17) + """ + if not str(cppstd).isdigit(): + raise ConanException("cppstd parameter must be a number") + + def less_than(lhs, rhs): + def extract_cpp_version(_cppstd): + return str(_cppstd).replace("gnu", "") + + def add_millennium(_cppstd): + return "19%s" % _cppstd if _cppstd == "98" else "20%s" % _cppstd + + lhs = add_millennium(extract_cpp_version(lhs)) + rhs = add_millennium(extract_cpp_version(rhs)) + return lhs < rhs + + current_cppstd = conanfile.settings.get_safe("compiler.cppstd") + if current_cppstd is None: + raise ConanInvalidConfiguration("The compiler.cppstd is not defined for this configuration") + + if gnu_extensions and "gnu" not in current_cppstd: + raise ConanInvalidConfiguration("The cppstd GNU extension is required") + + if less_than(current_cppstd, cppstd): + raise ConanInvalidConfiguration("Current cppstd ({}) is lower than the required C++ " + "standard ({}).".format(current_cppstd, cppstd)) + + +def valid_min_cppstd(conanfile, cppstd, gnu_extensions=False): + """ Validate if current cppstd fits the minimal version required. + + :param conanfile: The current recipe object. Always use ``self``. + :param cppstd: Minimal cppstd version required + :param gnu_extensions: GNU extension is required (e.g gnu17). This option ONLY works on Linux. + :return: True, if current cppstd matches the required cppstd version. Otherwise, False. + """ + try: + check_min_cppstd(conanfile, cppstd, gnu_extensions) + except ConanInvalidConfiguration: + return False + return True + + +def default_cppstd(conanfile, compiler=None, compiler_version=None): + """ + Get the default ``compiler.cppstd`` for the "conanfile.settings.compiler" and "conanfile + settings.compiler_version" or for the parameters "compiler" and "compiler_version" if specified. + + :param conanfile: The current recipe object. Always use ``self``. + :param compiler: Name of the compiler e.g. gcc + :param compiler_version: Version of the compiler e.g. 12 + :return: The default ``compiler.cppstd`` for the specified compiler + """ + from conans.client.conf.detect import _cppstd_default + compiler = compiler or conanfile.settings.get_safe("compiler") + compiler_version = compiler_version or conanfile.settings.get_safe("compiler.version") + if not compiler or not compiler_version: + raise ConanException("Called default_cppstd with no compiler or no compiler.version") + return _cppstd_default(compiler, Version(compiler_version)) + + +def supported_cppstd(conanfile, compiler=None, compiler_version=None): + """ + Get the a list of supported ``compiler.cppstd`` for the "conanfile.settings.compiler" and + "conanfile.settings.compiler_version" or for the parameters "compiler" and "compiler_version" + if specified. + + :param conanfile: The current recipe object. Always use ``self``. + :param compiler: Name of the compiler e.g: gcc + :param compiler_version: Version of the compiler e.g: 12 + :return: a list of supported ``cppstd`` values. + """ + compiler = compiler or conanfile.settings.get_safe("compiler") + compiler_version = compiler_version or conanfile.settings.get_safe("compiler.version") + if not compiler or not compiler_version: + raise ConanException("Called supported_cppstd with no compiler or no compiler.version") + + func = {"apple-clang": _apple_clang_supported_cppstd, + "gcc": _gcc_supported_cppstd, + "msvc": _msvc_supported_cppstd, + "clang": _clang_supported_cppstd, + "mcst-lcc": _mcst_lcc_supported_cppstd}.get(compiler) + if func: + return func(Version(compiler_version)) + return None + + +def _apple_clang_supported_cppstd(version): + """ + ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20"] + """ + if version < "4.0": + return [] + if version < "5.1": + return ["98", "gnu98", "11", "gnu11"] + if version < "6.1": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14"] + if version < "10.0": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17"] + + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20"] + + +def _gcc_supported_cppstd(version): + """ + ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20", "23", "gnu23"] + """ + if version < "3.4": + return [] + if version < "4.3": + return ["98", "gnu98"] + if version < "4.8": + return ["98", "gnu98", "11", "gnu11"] + if version < "5": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14"] + if version < "8": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17"] + if version < "11": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20"] + + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20", "23", "gnu23"] + + +def _msvc_supported_cppstd(version): + """ + [14, 17, 20, 23] + """ + if version < "190": + return [] + if version < "191": + return ["14", "17"] + if version < "193": + return ["14", "17", "20"] + + return ["14", "17", "20", "23"] + + +def _clang_supported_cppstd(version): + """ + ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20", "23", "gnu23"] + """ + if version < "2.1": + return [] + if version < "3.4": + return ["98", "gnu98", "11", "gnu11"] + if version < "3.5": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14"] + if version < "6": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17"] + if version < "12": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20"] + + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20", "23", "gnu23"] + + +def _mcst_lcc_supported_cppstd(version): + """ + ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20", "23", "gnu23"] + """ + + if version < "1.21": + return ["98", "gnu98"] + if version < "1.24": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14"] + if version < "1.25": + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17"] + + # FIXME: When cppstd 23 was introduced???? + + return ["98", "gnu98", "11", "gnu11", "14", "gnu14", "17", "gnu17", "20", "gnu20"] diff --git a/conans/test/unittests/tools/build/test_cppstd.py b/conans/test/unittests/tools/build/test_cppstd.py new file mode 100644 index 00000000000..f81c3046571 --- /dev/null +++ b/conans/test/unittests/tools/build/test_cppstd.py @@ -0,0 +1,192 @@ +import pytest + +from conan.tools.build import supported_cppstd, check_min_cppstd, valid_min_cppstd +from conans.errors import ConanException, ConanInvalidConfiguration +from conans.test.utils.mocks import MockSettings, MockConanfile + + +@pytest.mark.parametrize("compiler,compiler_version,values", [ + ("clang", "2.0", []), + ("clang", "2.1", ['98', 'gnu98', '11', 'gnu11']), + ("clang", "2.2", ['98', 'gnu98', '11', 'gnu11']), + ("clang", "3.1", ['98', 'gnu98', '11', 'gnu11']), + ("clang", "3.4", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14"]), + ("clang", "3.5", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("clang", "4.9", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("clang", "5", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("clang", "6", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", "gnu20"]), + ("clang", "12", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", + "gnu20", "23", "gnu23"]) +]) +def test_supported_cppstd_clang(compiler, compiler_version, values): + settings = MockSettings({"compiler": compiler, "compiler.version": compiler_version}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile) + assert sot == values + + +def test_supported_cppstd_with_specific_values(): + settings = MockSettings({}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile, "clang", "3.1") + assert sot == ['98', 'gnu98', '11', 'gnu11'] + + +def test_supported_cppstd_error(): + settings = MockSettings({}) + conanfile = MockConanfile(settings) + with pytest.raises(ConanException) as exc: + supported_cppstd(conanfile) + assert "Called supported_cppstd with no compiler or no compiler.version" in str(exc) + + +@pytest.mark.parametrize("compiler,compiler_version,values", [ + ("gcc", "2.0", []), + ("gcc", "3.4", ['98', 'gnu98']), + ("gcc", "4.2", ['98', 'gnu98']), + ("gcc", "4.3", ['98', 'gnu98', '11', 'gnu11']), + ("gcc", "4.8", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14"]), + ("gcc", "5", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("gcc", "8", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", "gnu20"]), + ("gcc", "11", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", "gnu20", + "23", "gnu23"]) +]) +def test_supported_cppstd_gcc(compiler, compiler_version, values): + settings = MockSettings({"compiler": compiler, "compiler.version": compiler_version}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile) + assert sot == values + + +@pytest.mark.parametrize("compiler,compiler_version,values", [ + ("apple-clang", "3.9", []), + ("apple-clang", "4.0", ['98', 'gnu98', '11', 'gnu11']), + ("apple-clang", "5.0", ['98', 'gnu98', '11', 'gnu11']), + ("apple-clang", "5.1", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14"]), + ("apple-clang", "6.1", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("apple-clang", "9.5", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("apple-clang", "10", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", + "gnu20"]), +]) +def test_supported_cppstd_apple_clang(compiler, compiler_version, values): + settings = MockSettings({"compiler": compiler, "compiler.version": compiler_version}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile) + assert sot == values + + +@pytest.mark.parametrize("compiler,compiler_version,values", [ + ("msvc", "180", []), + ("msvc", "190", ['14', '17']), + ("msvc", "191", ['14', '17', '20']), + ("msvc", "193", ['14', '17', '20', '23']), +]) +def test_supported_cppstd_msvc(compiler, compiler_version, values): + settings = MockSettings({"compiler": compiler, "compiler.version": compiler_version}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile) + assert sot == values + + +@pytest.mark.parametrize("compiler,compiler_version,values", [ + ("mcst-lcc", "1.20", ['98', 'gnu98']), + ("mcst-lcc", "1.21", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14"]), + ("mcst-lcc", "1.23", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14"]), + ("mcst-lcc", "1.24", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17"]), + ("mcst-lcc", "1.25", ['98', 'gnu98', '11', 'gnu11', "14", "gnu14", "17", "gnu17", "20", "gnu20"]) +]) +def test_supported_cppstd_mcst(compiler, compiler_version, values): + settings = MockSettings({"compiler": compiler, "compiler.version": compiler_version}) + conanfile = MockConanfile(settings) + sot = supported_cppstd(conanfile) + assert sot == values + + +def test_check_cppstd_type(): + """ cppstd must be a number + """ + conanfile = MockConanfile(MockSettings({})) + with pytest.raises(ConanException) as exc: + check_min_cppstd(conanfile, "gnu17", False) + + assert "cppstd parameter must be a number", str(exc) + + +def _create_conanfile(compiler, version, os, cppstd, libcxx=None): + settings = MockSettings({"arch": "x86_64", + "build_type": "Debug", + "os": os, + "compiler": compiler, + "compiler.version": version, + "compiler.cppstd": cppstd}) + if libcxx: + settings.values["compiler.libcxx"] = libcxx + conanfile = MockConanfile(settings) + return conanfile + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14", "17"]) +def test_check_min_cppstd_from_settings(cppstd): + """ check_min_cppstd must accept cppstd less/equal than cppstd in settings + """ + conanfile = _create_conanfile("gcc", "9", "Linux", "17", "libstdc++") + check_min_cppstd(conanfile, cppstd, False) + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14"]) +def test_check_min_cppstd_from_outdated_settings(cppstd): + """ check_min_cppstd must raise when cppstd is greater when supported on settings + """ + conanfile = _create_conanfile("gcc", "9", "Linux", cppstd, "libstdc++") + with pytest.raises(ConanInvalidConfiguration) as exc: + check_min_cppstd(conanfile, "17", False) + assert "Current cppstd ({}) is lower than the required C++ standard (17)." \ + "".format(cppstd) == str(exc.value) + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14", "17"]) +def test_check_min_cppstd_from_settings_with_extension(cppstd): + """ current cppstd in settings must has GNU extension when extensions is enabled + """ + conanfile = _create_conanfile("gcc", "9", "Linux", "gnu17", "libstdc++") + check_min_cppstd(conanfile, cppstd, True) + + conanfile.settings.values["compiler.cppstd"] = "17" + with pytest.raises(ConanException) as raises: + check_min_cppstd(conanfile, cppstd, True) + assert "The cppstd GNU extension is required" == str(raises.value) + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14", "17"]) +def test_valid_min_cppstd_from_settings(cppstd): + """ valid_min_cppstd must accept cppstd less/equal than cppstd in settings + """ + conanfile = _create_conanfile("gcc", "9", "Linux", "17", "libstdc++") + assert valid_min_cppstd(conanfile, cppstd, False) + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14"]) +def test_valid_min_cppstd_from_outdated_settings(cppstd): + """ valid_min_cppstd returns False when cppstd is greater when supported on settings + """ + conanfile = _create_conanfile("gcc", "9", "Linux", cppstd, "libstdc++") + assert not valid_min_cppstd(conanfile, "17", False) + + +@pytest.mark.parametrize("cppstd", ["98", "11", "14", "17"]) +def test_valid_min_cppstd_from_settings_with_extension(cppstd): + """ valid_min_cppstd must returns True when current cppstd in settings has GNU extension and + extensions is enabled + """ + conanfile = _create_conanfile("gcc", "9", "Linux", "gnu17", "libstdc++") + assert valid_min_cppstd(conanfile, cppstd, True) + + conanfile.settings.values["compiler.cppstd"] = "17" + assert not valid_min_cppstd(conanfile, cppstd, True) + + +def test_valid_min_cppstd_unsupported_standard(): + """ valid_min_cppstd must returns False when the compiler does not support a standard + """ + conanfile = _create_conanfile("gcc", "9", "Linux", None, "libstdc++") + assert not valid_min_cppstd(conanfile, "42", False)