Repository: conan-io/conan
Pull request #11580 (2022-07-06 12:04:47)

validate_build over settings
Changelog: Feature: Introduced a `validate_build()` method in the recipes to validate if the package can be built according to `self.settings` and `self.options` instead of `self.info.settings` and `self.info.options` that it is used to validate the `binary`.
Docs: https://github.com/conan-io/docs/pull/XXXX



Patch:
diff --git a/conan/tools/apple/xcodedeps.py b/conan/tools/apple/xcodedeps.py
index e972423d2..a15c533d1 100644
--- a/conan/tools/apple/xcodedeps.py
+++ b/conan/tools/apple/xcodedeps.py
@@ -137,7 +137,7 @@ class XcodeDeps(object):
             'lib_dirs': " ".join('"{}"'.format(p) for p in _merged_vars("libdirs")),
             'libs': " ".join("-l{}".format(lib) for lib in _merged_vars("libs")),
             'system_libs': " ".join("-l{}".format(sys_lib) for sys_lib in _merged_vars("system_libs")),
-            'frameworksdirs': " ".join('"{}"'.format(p) for p in _merged_vars("frameworkdirs")),
+            'frameworkdirs': " ".join('"{}"'.format(p) for p in _merged_vars("frameworkdirs")),
             'frameworks': " ".join("-framework {}".format(framework) for framework in _merged_vars("frameworks")),
             'definitions': " ".join('"{}"'.format(p.replace('"', '\\"')) for p in _merged_vars("defines")),
             'c_compiler_flags': " ".join('"{}"'.format(p.replace('"', '\\"')) for p in _merged_vars("cflags")),
diff --git a/conan/tools/cmake/toolchain/blocks.py b/conan/tools/cmake/toolchain/blocks.py
index 1f0d0a198..5db4af04e 100644
--- a/conan/tools/cmake/toolchain/blocks.py
+++ b/conan/tools/cmake/toolchain/blocks.py
@@ -854,13 +854,21 @@ class OutputDirsBlock(Block):
 
         return textwrap.dedent("""
            set(CMAKE_INSTALL_PREFIX "{{package_folder}}")
+           {% if default_bin %}
            set(CMAKE_INSTALL_BINDIR "{{default_bin}}")
            set(CMAKE_INSTALL_SBINDIR "{{default_bin}}")
            set(CMAKE_INSTALL_LIBEXECDIR "{{default_bin}}")
+           {% endif %}
+           {% if default_lib %}
            set(CMAKE_INSTALL_LIBDIR "{{default_lib}}")
+           {% endif %}
+           {% if default_include %}
            set(CMAKE_INSTALL_INCLUDEDIR "{{default_include}}")
            set(CMAKE_INSTALL_OLDINCLUDEDIR "{{default_include}}")
+           {% endif %}
+           {% if default_res %}
            set(CMAKE_INSTALL_DATAROOTDIR "{{default_res}}")
+           {% endif %}
         """)
 
     def _get_cpp_info_value(self, name):
diff --git a/conans/client/conan_command_output.py b/conans/client/conan_command_output.py
index 588b48240..91a6fff6a 100644
--- a/conans/client/conan_command_output.py
+++ b/conans/client/conan_command_output.py
@@ -140,6 +140,10 @@ class CommandOutputer(object):
             item_data["build_id"] = build_id(conanfile)
             item_data["context"] = conanfile.context
 
+            item_data["invalid_build"] = node.cant_build is not False
+            if node.cant_build:
+                item_data["invalid_build_reason"] = node.cant_build
+
             python_requires = getattr(conanfile, "python_requires", None)
             if python_requires and not isinstance(python_requires, dict):  # no old python requires
                 item_data["python_requires"] = [repr(r)
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index dbfc5ea5c..fbcfac5c5 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -91,6 +91,8 @@ class Node(object):
         self.id_direct_prefs = None
         self.id_indirect_prefs = None
 
+        self.cant_build = False  # It will set to a str with a reason if the validate_build() fails
+
     @property
     def id(self):
         return self._id
diff --git a/conans/client/graph/graph_binaries.py b/conans/client/graph/graph_binaries.py
index 103832b2a..4977e2d68 100644
--- a/conans/client/graph/graph_binaries.py
+++ b/conans/client/graph/graph_binaries.py
@@ -48,7 +48,10 @@ class GraphBinariesAnalyzer(object):
                     break
         if build_mode.forced(conanfile, ref, with_deps_to_build):
             conanfile.output.info('Forced build from source')
-            node.binary = BINARY_BUILD
+            if node.cant_build:
+                node.binary = BINARY_INVALID
+            else:
+                node.binary = BINARY_BUILD
             node.prev = None
             return True
 
@@ -178,7 +181,10 @@ class GraphBinariesAnalyzer(object):
             pref = PackageReference(locked.ref, locked.package_id, locked.prev)  # Keep locked PREV
             self._process_node(node, pref, build_mode, update, remotes)
             if node.binary == BINARY_MISSING and build_mode.allowed(node.conanfile):
-                node.binary = BINARY_BUILD
+                if node.cant_build:
+                    node.binary = BINARY_INVALID
+                else:
+                    node.binary = BINARY_BUILD
             if node.binary == BINARY_BUILD:
                 locked.unlock_prev()
 
@@ -231,7 +237,10 @@ class GraphBinariesAnalyzer(object):
                     if node.binary == BINARY_MISSING and node.package_id == PACKAGE_ID_INVALID:
                         node.binary = BINARY_INVALID
                 if node.binary == BINARY_MISSING and build_mode.allowed(node.conanfile):
-                    node.binary = BINARY_BUILD
+                    if node.cant_build:
+                        node.binary = BINARY_INVALID
+                    else:
+                        node.binary = BINARY_BUILD
 
             if locked:
                 # package_id was not locked, this means a base lockfile that is being completed
@@ -253,6 +262,8 @@ class GraphBinariesAnalyzer(object):
             node.binary = BINARY_INVALID
             return
 
+
+
         if self._evaluate_build(node, build_mode):
             return
 
@@ -293,7 +304,10 @@ class GraphBinariesAnalyzer(object):
                 local_recipe_hash = package_layout.recipe_manifest().summary_hash
                 if local_recipe_hash != recipe_hash:
                     conanfile.output.info("Outdated package!")
-                    node.binary = BINARY_BUILD
+                    if node.cant_build:
+                        node.binary = BINARY_INVALID
+                    else:
+                        node.binary = BINARY_BUILD
                     node.prev = None
                 else:
                     conanfile.output.info("Package is up to date")
@@ -400,6 +414,14 @@ class GraphBinariesAnalyzer(object):
                 except ConanInvalidConfiguration as e:
                     conanfile.info.invalid = str(e)
 
+        if hasattr(conanfile, "validate_build") and callable(conanfile.validate_build):
+            with conanfile_exception_formatter(str(conanfile), "validate_build"):
+                try:
+                    conanfile.validate_build()
+                except ConanInvalidConfiguration as e:
+                    # This 'cant_build' will be ignored if we don't have to build the node.
+                    node.cant_build = str(e)
+
         info = conanfile.info
         node.package_id = info.package_id()
 
diff --git a/conans/client/graph/graph_builder.py b/conans/client/graph/graph_builder.py
index 9f9d3baa6..05b7ef1df 100644
--- a/conans/client/graph/graph_builder.py
+++ b/conans/client/graph/graph_builder.py
@@ -3,7 +3,7 @@ import time
 from conans.client.conanfile.configure import run_configure_method
 from conans.client.graph.graph import DepsGraph, Node, RECIPE_EDITABLE, CONTEXT_HOST, CONTEXT_BUILD
 from conans.errors import (ConanException, ConanExceptionInUserConanfileMethod,
-                           conanfile_exception_formatter)
+                           conanfile_exception_formatter, ConanInvalidConfiguration)
 from conans.model.conan_file import get_env_context_manager
 from conans.model.ref import ConanFileReference
 from conans.model.requires import Requirements, Requirement
@@ -65,7 +65,9 @@ class DepsGraphBuilder(object):
         t1 = time.time()
         self._expand_node(root_node, dep_graph, Requirements(), None, None, check_updates,
                           update, remotes, profile_host, profile_build, graph_lock)
+
         logger.debug("GRAPH: Time to load deps %s" % (time.time() - t1))
+
         return dep_graph
 
     def extend_build_requires(self, graph, node, build_requires_refs, check_updates, update,
@@ -471,4 +473,5 @@ class DepsGraphBuilder(object):
 
         dep_graph.add_node(new_node)
         dep_graph.add_edge(current_node, new_node, requirement)
+
         return new_node
diff --git a/conans/client/graph/graph_manager.py b/conans/client/graph/graph_manager.py
index 3568566e7..10a75d463 100644
--- a/conans/client/graph/graph_manager.py
+++ b/conans/client/graph/graph_manager.py
@@ -124,6 +124,7 @@ class GraphManager(object):
         root_node = self._load_root_node(reference, create_reference, profile_host, graph_lock,
                                          root_ref, lockfile_node_id, is_build_require,
                                          require_overrides)
+
         deps_graph = self._resolve_graph(root_node, profile_host, profile_build, graph_lock,
                                          build_mode, check_updates, update, remotes, recorder,
                                          apply_build_requires=apply_build_requires)
diff --git a/conans/client/installer.py b/conans/client/installer.py
index 65649b942..260c7dcdd 100644
--- a/conans/client/installer.py
+++ b/conans/client/installer.py
@@ -423,7 +423,13 @@ class BinaryInstaller(object):
         if invalid:
             msg = ["There are invalid packages (packages that cannot exist for this configuration):"]
             for node in invalid:
-                msg.append("{}: Invalid ID: {}".format(node.conanfile, node.conanfile.info.invalid))
+                if node.cant_build:
+                    msg.append("{}: Cannot build "
+                               "for this configuration: {}".format(node.conanfile,
+                                                                   node.cant_build))
+                else:
+                    msg.append("{}: Invalid ID: {}".format(node.conanfile,
+                                                           node.conanfile.info.invalid))
             raise ConanInvalidConfiguration("\n".join(msg))
         self._raise_missing(missing)
         processed_package_refs = {}


Test patch:
diff --git a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
index c7101504c..095ae5c61 100644
--- a/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
+++ b/conans/test/functional/toolchains/cmake/test_cmake_toolchain.py
@@ -918,3 +918,94 @@ def test_cmake_presets_forbidden_build_type():
     client.run("install . {}".format(settings_layout), assert_error=True)
     assert "Error, don't include 'settings.build_type' in the " \
            "'tools.cmake.cmake_layout:build_folder_vars' conf" in client.out
+
+
+def test_resdirs_cmake_install():
+    """If resdirs is declared, the CMAKE_INSTALL_DATAROOTDIR folder is set"""
+
+    client = TestClient(path_with_spaces=False)
+
+    conanfile = textwrap.dedent("""
+            from conan import ConanFile
+            from conan.tools.cmake import CMakeToolchain, CMake, cmake_layout
+
+            class AppConan(ConanFile):
+                settings = "os", "compiler", "build_type", "arch"
+                exports_sources = "CMakeLists.txt", "my_license"
+                name = "foo"
+                version = "1.0"
+
+                def generate(self):
+                    tc = CMakeToolchain(self)
+                    tc.generate()
+
+                def layout(self):
+                    self.cpp.package.resdirs = ["res"]
+
+                def build(self):
+                    cmake = CMake(self)
+                    cmake.configure()
+                    cmake.build()
+
+                def package(self):
+                    cmake = CMake(self)
+                    cmake.install()
+            """)
+
+    cmake = """
+    cmake_minimum_required(VERSION 3.15)
+    set(CMAKE_CXX_COMPILER_WORKS 1)
+    project(foo)
+    if(NOT CMAKE_INSTALL_DATAROOTDIR)
+        message(FATAL_ERROR "Cannot install stuff")
+    endif()
+    install(FILES my_license DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/licenses)
+    """
+
+    client.save({"conanfile.py": conanfile, "CMakeLists.txt": cmake, "my_license": "MIT"})
+    client.run("create .")
+    assert "/res/licenses/my_license" in client.out
+    assert "Packaged 1 file: my_license" in client.out
+
+
+def test_resdirs_none_cmake_install():
+    """If no resdirs are declared, the CMAKE_INSTALL_DATAROOTDIR folder is not set"""
+
+    client = TestClient(path_with_spaces=False)
+
+    conanfile = textwrap.dedent("""
+            from conan import ConanFile
+            from conan.tools.cmake import CMakeToolchain, CMake, cmake_layout
+
+            class AppConan(ConanFile):
+                settings = "os", "compiler", "build_type", "arch"
+                exports_sources = "CMakeLists.txt", "my_license"
+                name = "foo"
+                version = "1.0"
+
+                def generate(self):
+                    tc = CMakeToolchain(self)
+                    tc.generate()
+
+                def build(self):
+                    cmake = CMake(self)
+                    cmake.configure()
+                    cmake.build()
+
+                def package(self):
+                    cmake = CMake(self)
+                    cmake.install()
+            """)
+
+    cmake = """
+    cmake_minimum_required(VERSION 3.15)
+    set(CMAKE_CXX_COMPILER_WORKS 1)
+    project(foo)
+    if(NOT CMAKE_INSTALL_DATAROOTDIR)
+        message(FATAL_ERROR "Cannot install stuff")
+    endif()
+    """
+
+    client.save({"conanfile.py": conanfile, "CMakeLists.txt": cmake, "my_license": "MIT"})
+    client.run("create .", assert_error=True)
+    assert "Cannot install stuff" in client.out
diff --git a/conans/test/integration/graph/test_validate_build.py b/conans/test/integration/graph/test_validate_build.py
new file mode 100644
index 000000000..65ab33011
--- /dev/null
+++ b/conans/test/integration/graph/test_validate_build.py
@@ -0,0 +1,83 @@
+import json
+import textwrap
+
+from conans.test.assets.genconanfile import GenConanfile
+from conans.test.utils.tools import TestClient
+
+
+def test_basic_validate_build_test():
+
+    t = TestClient()
+    conanfile = textwrap.dedent("""
+    from conan import ConanFile
+    from conans.errors import ConanInvalidConfiguration
+
+    class myConan(ConanFile):
+        name = "foo"
+        version = "1.0"
+        settings = "os", "arch", "compiler"
+
+        def validate_build(self):
+            if self.settings.compiler == "gcc":
+                raise ConanInvalidConfiguration("This doesn't build in GCC")
+
+        def package_id(self):
+            del self.info.settings.compiler
+    """)
+
+    settings_gcc = "-s compiler=gcc -s compiler.libcxx=libstdc++11 -s compiler.version=11"
+    settings_clang = "-s compiler=clang -s compiler.libcxx=libc++ -s compiler.version=8"
+
+    t.save({"conanfile.py": conanfile})
+    t.run(f"create . {settings_gcc}", assert_error=True)
+
+    assert "foo/1.0: Cannot build for this configuration: This doesn't build in GCC" in t.out
+
+    t.run(f"create . {settings_clang}")
+
+    # Now with GCC again, but now we have the binary, we don't need to build, so it doesn't fail
+    t.run(f"create . {settings_gcc} --build missing")
+    assert "foo/1.0: Already installed!" in t.out
+
+    # But if I force the build... it will fail
+    t.run(f"create . {settings_gcc} ", assert_error=True)
+    assert "foo/1.0: Cannot build for this configuration: This doesn't build in GCC" in t.out
+
+    # What happens with a conan info?
+    t.run(f"info foo/1.0@ {settings_gcc} --json=myjson")
+    myjson = json.loads(t.load("myjson"))
+    assert myjson[0]["invalid_build"] is True
+    assert myjson[0]["invalid_build_reason"] == "This doesn't build in GCC"
+
+    t.run(f"info foo/1.0@ {settings_clang} --json=myjson")
+    myjson = json.loads(t.load("myjson"))
+    assert myjson[0]["invalid_build"] is False
+    assert "invalid_build_reason" not in myjson[0]
+
+
+def test_with_options_validate_build_test():
+    t = TestClient()
+    conanfile = textwrap.dedent("""
+    from conan import ConanFile
+    from conans.errors import ConanInvalidConfiguration
+
+    class myConan(ConanFile):
+        name = "foo"
+        version = "1.0"
+        options = {"my_option": [True, False]}
+        default_options = {"my_option": True}
+
+        def validate_build(self):
+            if not self.options.my_option:
+                raise ConanInvalidConfiguration("This doesn't build with False option")
+
+    """)
+    t.save({"conanfile.py": conanfile})
+    t.run("export .")
+    consumer = GenConanfile().with_require("foo/1.0").with_name("consumer").with_version("1.0")
+    t.save({"consumer.py": consumer})
+    t.run("create consumer.py --build missing -o foo/*:my_option=False", assert_error=True)
+    assert "foo/1.0: Cannot build for this configuration: This doesn't build " \
+           "with False option" in t.out
+
+    t.run("create consumer.py --build missing -o foo/*:my_option=True")
diff --git a/conans/test/integration/toolchains/apple/test_xcodedeps.py b/conans/test/integration/toolchains/apple/test_xcodedeps.py
index c60ea523a..32c9b2385 100644
--- a/conans/test/integration/toolchains/apple/test_xcodedeps.py
+++ b/conans/test/integration/toolchains/apple/test_xcodedeps.py
@@ -174,3 +174,25 @@ def test_xcodedeps_aggregate_components():
 
     # folders are aggregated
     assert "mylibdir" in component4_vars
+
+
+@pytest.mark.skipif(platform.system() != "Darwin", reason="Only for MacOS")
+def test_xcodedeps_frameworkdirs():
+    client = TestClient()
+
+    conanfile_py = textwrap.dedent("""
+        from conan import ConanFile
+        class LibConan(ConanFile):
+            settings = "os", "compiler", "build_type", "arch"
+            def package_info(self):
+                self.cpp_info.frameworkdirs = ["lib_a_frameworkdir"]
+        """)
+
+    client.save({"conanfile.py": conanfile_py})
+    client.run("create conanfile.py lib_a/1.0@")
+
+    client.run("install lib_a/1.0@ -g XcodeDeps")
+
+    lib_a_xcconfig = client.load("conan_lib_a_lib_a_release_x86_64.xcconfig")
+
+    assert "lib_a_frameworkdir" in lib_a_xcconfig