Repository: conan-io/conan
Pull request #17725 (2025-02-07 07:01:04)

[incubating][CMakeDeps] Apple Frameworks
Changelog: Feature: Added complete Apple Frameworks management to `CMakeConfigDeps`.
Changelog: Feature: Added new `cpp_info.package_framework` to `cpp_info`.
Docs: https://github.com/conan-io/docs/pull/4017
Closes: https://github.com/conan-io/conan/issues/13017
Closes: https://github.com/conan-io/conan/issues/10140
Closes: https://github.com/conan-io/conan/issues/17224



Patch:
diff --git a/conan/internal/model/cpp_info.py b/conan/internal/model/cpp_info.py
index 7967b75bf..1a702ba6e 100644
--- a/conan/internal/model/cpp_info.py
+++ b/conan/internal/model/cpp_info.py
@@ -12,8 +12,8 @@ from conans.util.files import load, save
 
 _DIRS_VAR_NAMES = ["_includedirs", "_srcdirs", "_libdirs", "_resdirs", "_bindirs", "_builddirs",
                    "_frameworkdirs", "_objects"]
-_FIELD_VAR_NAMES = ["_system_libs", "_frameworks", "_libs", "_defines", "_cflags", "_cxxflags",
-                    "_sharedlinkflags", "_exelinkflags"]
+_FIELD_VAR_NAMES = ["_system_libs", "_package_framework", "_frameworks", "_libs", "_defines",
+                    "_cflags", "_cxxflags", "_sharedlinkflags", "_exelinkflags"]
 _ALL_NAMES = _DIRS_VAR_NAMES + _FIELD_VAR_NAMES
 
 
@@ -72,7 +72,8 @@ class _Component:
 
         # ##### FIELDS
         self._system_libs = None  # Ordered list of system libraries
-        self._frameworks = None  # Macos .framework
+        self._frameworks = None  # system Apple OS frameworks
+        self._package_framework = None  # any other frameworks
         self._libs = None  # The libs to link against
         self._defines = None  # preprocessor definitions
         self._cflags = None  # pure C flags
@@ -204,16 +205,6 @@ class _Component:
     def builddirs(self, value):
         self._builddirs = value
 
-    @property
-    def frameworkdirs(self):
-        if self._frameworkdirs is None:
-            self._frameworkdirs = []
-        return self._frameworkdirs
-
-    @frameworkdirs.setter
-    def frameworkdirs(self, value):
-        self._frameworkdirs = value
-
     @property
     def bindir(self):
         bindirs = self.bindirs
@@ -251,6 +242,14 @@ class _Component:
     def system_libs(self, value):
         self._system_libs = value
 
+    @property
+    def package_framework(self):
+        return self._package_framework
+
+    @package_framework.setter
+    def package_framework(self, value):
+        self._package_framework = value
+
     @property
     def frameworks(self):
         if self._frameworks is None:
@@ -261,6 +260,16 @@ class _Component:
     def frameworks(self, value):
         self._frameworks = value
 
+    @property
+    def frameworkdirs(self):
+        if self._frameworkdirs is None:
+            self._frameworkdirs = []
+        return self._frameworkdirs
+
+    @frameworkdirs.setter
+    def frameworkdirs(self, value):
+        self._frameworkdirs = value
+
     @property
     def libs(self):
         if self._libs is None:
@@ -612,7 +621,7 @@ class _Component:
                 raise ConanException(f"{name} has .exe and no .location")
             return
         if self._type is PackageType.APP:
-            # old school Conan application packages withoud defining an exe, not an error
+            # old school Conan application packages without defining an exe, not an error
             return
 
         # libraries
@@ -817,7 +826,6 @@ class CppInfo:
             raise ConanException(f"{conanfile}: 'cpp_info' contains components and .exe or .libs")
 
         result = CppInfo()  # clone it
-
         if self.libs and len(self.libs) > 1:  # expand in multiple components
             ConanOutput().warning(f"{conanfile}: The 'cpp_info.libs' contain more than 1 library. "
                                   "Define 'cpp_info.components' instead.")
diff --git a/conan/tools/cmake/cmakedeps2/cmakedeps.py b/conan/tools/cmake/cmakedeps2/cmakedeps.py
index cae3326c0..3030b982b 100644
--- a/conan/tools/cmake/cmakedeps2/cmakedeps.py
+++ b/conan/tools/cmake/cmakedeps2/cmakedeps.py
@@ -5,15 +5,15 @@ import textwrap
 from jinja2 import Template
 
 from conan.api.output import Color
+from conan.errors import ConanException
 from conan.internal import check_duplicated_generator
 from conan.internal.api.install.generators import relativize_path
+from conan.internal.model.dependencies import get_transitive_requires
 from conan.tools.cmake.cmakedeps2.config import ConfigTemplate2
 from conan.tools.cmake.cmakedeps2.config_version import ConfigVersionTemplate2
 from conan.tools.cmake.cmakedeps2.target_configuration import TargetConfigurationTemplate2
 from conan.tools.cmake.cmakedeps2.targets import TargetsTemplate2
 from conan.tools.files import save
-from conan.errors import ConanException
-from conan.internal.model.dependencies import get_transitive_requires
 from conans.util.files import load
 
 FIND_MODE_MODULE = "module"
@@ -173,6 +173,7 @@ class _PathGenerator:
             "bindirs": "CMAKE_PROGRAM_PATH",
             "libdirs": "CMAKE_LIBRARY_PATH",
             "includedirs": "CMAKE_INCLUDE_PATH",
+            "frameworkdirs": "CMAKE_FRAMEWORK_PATH",
         }
         for req, dep in requirements:
             cppinfo = dep.cpp_info.aggregated_components()
@@ -206,11 +207,15 @@ class _PathGenerator:
         {% if cmake_include_path %}
         list(PREPEND CMAKE_INCLUDE_PATH {{ cmake_include_path }})
         {% endif %}
+        {% if cmake_framework_path %}
+        list(PREPEND CMAKE_FRAMEWORK_PATH {{ cmake_framework_path }})
+        {% endif %}
         """)
         host_req = self._conanfile.dependencies.host
         build_req = self._conanfile.dependencies.direct_build
         test_req = self._conanfile.dependencies.test
-        all_reqs = list(host_req.items()) + list(test_req.items()) + list(build_req.items())
+        host_test_reqs = list(host_req.items()) + list(test_req.items())
+        all_reqs = host_test_reqs + list(build_req.items())
         # gen_folder = self._conanfile.generators_folder.replace("\\", "/")
         # if not, test_cmake_add_subdirectory test fails
         # content.append('set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON)')
@@ -243,14 +248,15 @@ class _PathGenerator:
 
         # CMAKE_PROGRAM_PATH | CMAKE_LIBRARY_PATH | CMAKE_INCLUDE_PATH
         cmake_program_path = self._get_cmake_paths([(req, dep) for req, dep in all_reqs if req.direct], "bindirs")
-        cmake_library_path = self._get_cmake_paths(list(host_req.items()) + list(test_req.items()), "libdirs")
-        cmake_include_path = self._get_cmake_paths(list(host_req.items()) + list(test_req.items()), "includedirs")
-
+        cmake_library_path = self._get_cmake_paths(host_test_reqs, "libdirs")
+        cmake_include_path = self._get_cmake_paths(host_test_reqs, "includedirs")
+        cmake_framework_path = self._get_cmake_paths(host_test_reqs, "frameworkdirs")
         context = {"host_runtime_dirs": self._get_host_runtime_dirs(),
                    "pkg_paths": pkg_paths,
                    "cmake_program_path": _join_paths(self._conanfile, cmake_program_path),
                    "cmake_library_path": _join_paths(self._conanfile, cmake_library_path),
                    "cmake_include_path": _join_paths(self._conanfile, cmake_include_path),
+                   "cmake_framework_path": _join_paths(self._conanfile, cmake_framework_path),
         }
         content = Template(template, trim_blocks=True, lstrip_blocks=True).render(context)
         save(self._conanfile, self._conan_cmakedeps_paths, content)
diff --git a/conan/tools/cmake/cmakedeps2/target_configuration.py b/conan/tools/cmake/cmakedeps2/target_configuration.py
index e85df76fd..9f303c75f 100644
--- a/conan/tools/cmake/cmakedeps2/target_configuration.py
+++ b/conan/tools/cmake/cmakedeps2/target_configuration.py
@@ -4,10 +4,9 @@ import textwrap
 import jinja2
 from jinja2 import Template
 
-from conan.api.output import ConanOutput
 from conan.errors import ConanException
-from conans.client.graph.graph import CONTEXT_BUILD, CONTEXT_HOST
 from conan.internal.model.pkg_type import PackageType
+from conans.client.graph.graph import CONTEXT_BUILD, CONTEXT_HOST
 
 
 class TargetConfigurationTemplate2:
@@ -138,7 +137,7 @@ class TargetConfigurationTemplate2:
                 "version": self._conanfile.ref.version,
                 "include_dirs": include_dirs,
                 "definitions": definitions,
-                "libraries": libraries
+                "libraries": libraries,
                 }
 
     def _get_libs(self, cpp_info, pkg_name, pkg_folder, pkg_folder_var) -> dict:
@@ -161,7 +160,7 @@ class TargetConfigurationTemplate2:
         return libs
 
     def _get_cmake_lib(self, info, components, pkg_folder, pkg_folder_var):
-        if info.exe or not (info.includedirs or info.libs or info.system_libs):
+        if info.exe or not (info.package_framework or info.includedirs or info.libs or info.system_libs):
             return
 
         includedirs = ";".join(self._path(i, pkg_folder, pkg_folder_var)
@@ -182,12 +181,25 @@ class TargetConfigurationTemplate2:
                   "cflags": " ".join(info.cflags),
                   "sharedlinkflags": " ".join(info.sharedlinkflags),
                   "exelinkflags": " ".join(info.exelinkflags),
-                  "system_libs": system_libs}
-
+                  "system_libs": system_libs
+        }
+        # System frameworks (only Apple OS)
         if info.frameworks:
-            ConanOutput(scope=str(self._conanfile)).warning("frameworks not supported yet in new CMakeDeps generator")
-
-        if info.libs:  # TODO: to change to location
+            target['frameworks'] = " ".join([f"-framework {frw}" for frw in info.frameworks])
+        # FIXME: We're ignoring this value at this moment. It relies on cmake_target_name or lib name
+        #        Revisit when cpp.exe value is used too.
+        if info.package_framework:
+            target["package_framework"] = {}
+            lib_type = "SHARED" if info.type is PackageType.SHARED else \
+                "STATIC" if info.type is PackageType.STATIC else "STATIC"
+            assert lib_type, f"Unknown package type {info.type}"
+            assert info.location, f"cpp_info.location missing for framework {info.package_framework}"
+            target["type"] = lib_type
+            target["package_framework"]["location"] = self._path(info.location, pkg_folder, pkg_folder_var)
+            target["includedirs"] = []  # empty array as frameworks have their own way to inject headers
+            # FIXME: This is not needed for CMake < 3.24. Remove it when Conan requires CMake >= 3.24
+            target["package_framework"]["frameworkdir"] = self._path(pkg_folder, pkg_folder, pkg_folder_var)
+        if info.libs:
             if len(info.libs) != 1:
                 raise ConanException(f"New CMakeDeps only allows 1 lib per component:\n"
                                      f"{self._conanfile}: {info.libs}")
@@ -204,7 +216,6 @@ class TargetConfigurationTemplate2:
             link_languages = info.languages or self._conanfile.languages or []
             link_languages = ["CXX" if c == "C++" else c for c in link_languages]
             target["link_languages"] = link_languages
-
         return target
 
     def _add_root_lib_target(self, libs, pkg_name, cpp_info):
@@ -279,6 +290,7 @@ class TargetConfigurationTemplate2:
 
     @property
     def _template(self):
+        # TODO: CMake 3.24: Apple Frameworks: https://cmake.org/cmake/help/latest/manual/cmake-generator-expressions.7.html#genex:LINK_LIBRARY
         # TODO: Check why not set_property instead of target_link_libraries
         return textwrap.dedent("""\
         {%- macro config_wrapper(config, value) -%}
@@ -380,7 +392,20 @@ class TargetConfigurationTemplate2:
         {% if lib_info.get("system_libs") %}
         target_link_libraries({{lib}} INTERFACE {{lib_info["system_libs"]}})
         {% endif %}
-
+        {% if lib_info.get("frameworks") %}
+        set_property(TARGET {{lib}} PROPERTY INTERFACE_LINK_LIBRARIES "{{config_wrapper(config, lib_info["frameworks"])}}")
+        {% endif %}
+        {% if lib_info.get("package_framework") %}
+        set_target_properties({{lib}} PROPERTIES
+            IMPORTED_LOCATION_{{config}} "{{lib_info["package_framework"]["location"]}}"
+            FRAMEWORK TRUE)
+        if(CMAKE_VERSION VERSION_LESS "3.24")
+            set_property(TARGET {{lib}} APPEND PROPERTY INTERFACE_COMPILE_OPTIONS
+                         $<$<COMPILE_LANGUAGE:CXX>:-F{{lib_info["package_framework"]["frameworkdir"]}}>)
+            set_property(TARGET {{lib}} APPEND PROPERTY INTERFACE_COMPILE_OPTIONS
+                         $<$<COMPILE_LANGUAGE:C>:-F{{lib_info["package_framework"]["frameworkdir"]}}>)
+        endif()
+        {% endif %}
         {% endfor %}
 
         ################# Global variables for try compile and legacy ##############


Test patch:
diff --git a/test/functional/toolchains/cmake/cmakedeps2/__init__.py b/test/functional/toolchains/cmake/cmakedeps2/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py b/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py
new file mode 100644
index 000000000..9f98a402a
--- /dev/null
+++ b/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py
@@ -0,0 +1,137 @@
+import platform
+import textwrap
+
+import pytest
+
+from conan.internal.model.pkg_type import PackageType
+from conan.test.utils.tools import TestClient
+
+new_value = "will_break_next"
+
+
+@pytest.mark.parametrize("shared", [True, False])
+@pytest.mark.tool("cmake")
+@pytest.mark.skipif(platform.system() != "Darwin", reason="Only OSX")
+def test_osx_frameworks(shared):
+    """
+    Testing custom package frameworks + system frameworks
+    """
+    cmakelists = textwrap.dedent("""
+    cmake_minimum_required(VERSION 3.15)
+    project(MyFramework CXX)
+
+    add_library(MyFramework frame.cpp frame.h)
+
+    set_target_properties(MyFramework PROPERTIES
+      FRAMEWORK TRUE
+      FRAMEWORK_VERSION C # Version "A" is macOS convention
+      MACOSX_FRAMEWORK_IDENTIFIER MyFramework
+      PUBLIC_HEADER frame.h
+    )
+
+    install(TARGETS MyFramework
+      FRAMEWORK DESTINATION .)
+    """)
+    frame_cpp = textwrap.dedent("""
+    #include "frame.h"
+    #include <iostream>
+    #include <CoreFoundation/CoreFoundation.h>
+
+    void greet() {
+        CFTypeRef keys[] = {CFSTR("key")};
+        CFTypeRef values[] = {CFSTR("value")};
+        CFDictionaryRef dict = CFDictionaryCreate(kCFAllocatorDefault, keys, values, sizeof(keys) / sizeof(keys[0]),
+                        &kCFTypeDictionaryKeyCallBacks, &kCFTypeDictionaryValueCallBacks);
+        if (dict)
+            CFRelease(dict);
+        std::cout << "Hello from MyFramework!" << std::endl;
+    }
+    """)
+    frame_h = textwrap.dedent("""
+    #pragma once
+    #include <vector>
+    #include <string>
+    void greet();
+    """)
+    cpp_info_type = PackageType.SHARED if shared else PackageType.STATIC
+    conanfile = textwrap.dedent(f"""
+    import os
+    from conan import ConanFile
+    from conan.tools.cmake import CMake
+
+    class MyFramework(ConanFile):
+        name = "frame"
+        version = "1.0"
+        settings = "os", "arch", "compiler", "build_type"
+        languages = ["C++"]
+        package_type = "{'static-library' if shared else 'shared-library'}"
+        exports_sources = ["frame.cpp", "frame.h", "CMakeLists.txt"]
+        generators = "CMakeToolchain"
+
+        def build(self):
+            cmake = CMake(self)
+            cmake.configure()
+            cmake.build()
+
+        def package(self):
+            cmake = CMake(self)
+            cmake.install()
+
+        def package_info(self):
+            self.cpp_info.type = "{cpp_info_type}"
+            self.cpp_info.package_framework = "MyFramework"
+            self.cpp_info.location = os.path.join(self.package_folder, "MyFramework.framework", "MyFramework")
+            # Using also a system framework
+            self.cpp_info.frameworks = ["CoreFoundation"]
+    """)
+    test_main_cpp = textwrap.dedent("""
+    #include <MyFramework/frame.h>
+    int main() {
+        greet();
+    }
+    """)
+    test_conanfile = textwrap.dedent("""
+    import os
+    from conan import ConanFile
+    from conan.tools.cmake import CMake, cmake_layout
+    from conan.tools.build import can_run
+
+    class LibTestConan(ConanFile):
+        settings = "os", "compiler", "build_type", "arch"
+        generators = "CMakeConfigDeps", "CMakeToolchain"
+
+        def requirements(self):
+            self.requires(self.tested_reference_str)
+
+        def build(self):
+            cmake = CMake(self)
+            cmake.configure()
+            cmake.build()
+
+        def layout(self):
+            cmake_layout(self)
+
+        def test(self):
+            if can_run(self):
+                cmd = os.path.join(self.cpp.build.bindir, "example")
+                self.run(cmd, env="conanrun")
+    """)
+    test_cmakelists = textwrap.dedent("""
+    cmake_minimum_required(VERSION 3.15)
+    project(PackageTest CXX)
+    find_package(frame CONFIG REQUIRED)
+    add_executable(example main.cpp)
+    target_link_libraries(example frame::frame)
+    """)
+    client = TestClient()
+    client.save({
+        'test_package/main.cpp': test_main_cpp,
+        'test_package/CMakeLists.txt': test_cmakelists,
+        'test_package/conanfile.py': test_conanfile,
+        'CMakeLists.txt': cmakelists,
+        'frame.cpp': frame_cpp,
+        'frame.h': frame_h,
+        'conanfile.py': conanfile
+    })
+    client.run(f"create . -c tools.cmake.cmakedeps:new={new_value} -o '*:shared={shared}'")
+    assert "Hello from MyFramework!" in client.out
diff --git a/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py b/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new.py
similarity index 100%
rename from test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py
rename to test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new.py
diff --git a/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new_cpp_linkage.py b/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new_cpp_linkage.py
similarity index 100%
rename from test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new_cpp_linkage.py
rename to test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new_cpp_linkage.py
diff --git a/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new_paths.py b/test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new_paths.py
similarity index 100%
rename from test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new_paths.py
rename to test/functional/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_new_paths.py
diff --git a/test/integration/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py b/test/integration/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py
new file mode 100644
index 000000000..ebbd28533
--- /dev/null
+++ b/test/integration/toolchains/cmake/cmakedeps2/test_cmakeconfigdeps_frameworks.py
@@ -0,0 +1,44 @@
+import platform
+import textwrap
+
+import pytest
+
+from conan.test.utils.tools import TestClient
+
+new_value = "will_break_next"
+
+
+@pytest.mark.skipif(platform.system() != "Darwin", reason="Only OSX")
+def test_package_framework_needs_location():
+    conanfile = textwrap.dedent(f"""
+    import os
+    from conan import ConanFile
+
+    class MyFramework(ConanFile):
+        name = "frame"
+        version = "1.0"
+        settings = "os", "arch", "compiler", "build_type"
+        package_type = 'static-library'
+
+        def package_info(self):
+            self.cpp_info.type = 'static-library'
+            self.cpp_info.package_framework = "MyFramework"
+    """)
+    test_conanfile = textwrap.dedent("""
+    import os
+    from conan import ConanFile
+
+    class LibTestConan(ConanFile):
+        settings = "os", "compiler", "build_type", "arch"
+        generators = "CMakeConfigDeps", "CMakeToolchain"
+
+        def requirements(self):
+            self.requires(self.tested_reference_str)
+    """)
+    client = TestClient()
+    client.save({
+        'test_package/conanfile.py': test_conanfile,
+        'conanfile.py': conanfile
+    })
+    client.run(f"create . -c tools.cmake.cmakedeps:new={new_value}", assert_error=True)
+    assert "Error in generator 'CMakeConfigDeps': cpp_info.location missing for framework MyFramework" in client.out
diff --git a/test/integration/toolchains/cmake/cmakedeps2/test_cmakedeps.py b/test/integration/toolchains/cmake/cmakedeps2/test_cmakedeps.py
index e54bb2a61..82b899cf6 100644
--- a/test/integration/toolchains/cmake/cmakedeps2/test_cmakedeps.py
+++ b/test/integration/toolchains/cmake/cmakedeps2/test_cmakedeps.py
@@ -19,6 +19,7 @@ def test_cmakedeps_direct_deps_paths():
             def package_info(self):
                 self.cpp_info.includedirs = ["myincludes"]
                 self.cpp_info.libdirs = ["mylib"]
+                self.cpp_info.frameworkdirs = ["myframework"]
     """)
     c.save({"conanfile.py": conanfile})
     c.run("create .")
@@ -39,6 +40,7 @@ def test_cmakedeps_direct_deps_paths():
     assert re.search(r"list\(PREPEND CMAKE_PROGRAM_PATH \".*/bin\"", cmake_paths)  # default
     assert re.search(r"list\(PREPEND CMAKE_LIBRARY_PATH \".*/mylib\"", cmake_paths)
     assert re.search(r"list\(PREPEND CMAKE_INCLUDE_PATH \".*/myincludes\"", cmake_paths)
+    assert re.search(r"list\(PREPEND CMAKE_FRAMEWORK_PATH \".*/myframework\"", cmake_paths)
 
 
 def test_cmakedeps_transitive_paths():