Repository: conan-io/conan
Pull request #16054 (2024-04-09 21:43:30)

Feature/cps
Changelog: Omit
Docs: Omit


Won't be documented yet




Patch:
diff --git a/conan/cps/__init__.py b/conan/cps/__init__.py
new file mode 100644
index 000000000..d2efed3ae
--- /dev/null
+++ b/conan/cps/__init__.py
@@ -0,0 +1,1 @@
+from conan.cps.cps import CPS
diff --git a/conan/cps/cps.py b/conan/cps/cps.py
new file mode 100644
index 000000000..cec264e65
--- /dev/null
+++ b/conan/cps/cps.py
@@ -0,0 +1,302 @@
+import glob
+import json
+import os
+from enum import Enum
+
+from conan.api.output import ConanOutput
+from conans.model.build_info import CppInfo
+from conans.model.pkg_type import PackageType
+from conans.util.files import save, load
+
+
+class CPSComponentType(Enum):
+    DYLIB = "dylib"
+    ARCHIVE = "archive"
+    INTERFACE = "interface"
+    EXE = "exe"
+    JAR = "jar"
+    UNKNOWN = "unknown"
+
+    def __str__(self):
+        return self.value
+
+    def __eq__(self, other):
+        # This is useful for comparing with string type at user code, like ``type == "xxx"``
+        return super().__eq__(CPSComponentType(other))
+
+    @staticmethod
+    def from_conan(pkg_type):
+        _package_type_map = {
+            "shared-library": "dylib",
+            "static-library": "archive",
+            "header-library": "interface",
+            "application": "executable"
+        }
+        return CPSComponentType(_package_type_map.get(str(pkg_type), "unknown"))
+
+
+def deduce_full_lib_info(libname, full_lib, cpp_info, pkg_type):
+    if full_lib.get("type") is not None:
+        assert "location" in full_lib, f"If 'type' is specified in library {libname}, 'location' too"
+        return
+
+    # Recipe didn't specify things, need to auto deduce
+    libdirs = [x.replace("\\", "/") for x in cpp_info.libdirs]
+    bindirs = [x.replace("\\", "/") for x in cpp_info.bindirs]
+
+    static_patterns = [f"{libname}.lib", f"{libname}.a", f"lib{libname}.a"]
+    shared_patterns = [f"lib{libname}.so", f"lib{libname}.so.*", f"lib{libname}.dylib",
+                       f"lib{libname}.*dylib"]
+    dll_patterns = [f"{libname}.dll"]
+
+    def _find_matching(patterns, dirs):
+        matches = set()
+        for pattern in patterns:
+            for d in dirs:
+                matches.update(glob.glob(f"{d}/{pattern}"))
+        if len(matches) == 1:
+            return next(iter(matches))
+
+    static_location = _find_matching(static_patterns, libdirs)
+    shared_location = _find_matching(shared_patterns, libdirs)
+    dll_location = _find_matching(dll_patterns, bindirs)
+    if static_location:
+        if shared_location:
+            ConanOutput().warning(f"Lib {libname} has both static {static_location} and "
+                                  f"shared {shared_location} in the same package")
+            if pkg_type is PackageType.STATIC:
+                full_lib["location"] = static_location
+                full_lib["type"] = PackageType.STATIC
+            else:
+                full_lib["location"] = shared_location
+                full_lib["type"] = PackageType.SHARED
+        elif dll_location:
+            full_lib["location"] = dll_location
+            full_lib["link_location"] = static_location
+            full_lib["type"] = PackageType.SHARED
+        else:
+            full_lib["location"] = static_location
+            full_lib["type"] = PackageType.STATIC
+    elif shared_location:
+        full_lib["location"] = shared_location
+        full_lib["type"] = PackageType.SHARED
+    elif dll_location:
+        # Only .dll but no link library
+        full_lib["location"] = dll_location
+        full_lib["type"] = PackageType.SHARED
+    if full_lib.get("type") != pkg_type:
+        ConanOutput().warning(f"Lib {libname} deduced as '{full_lib.get('type')}, "
+                              f"but 'package_type={pkg_type}'")
+
+
+class CPSComponent:
+    def __init__(self, component_type=None):
+        self.includes = []
+        self.type = component_type or "unknown"
+        self.definitions = []
+        self.requires = []
+        self.location = None
+        self.link_location = None
+        self.link_libraries = None  # system libraries
+
+    def serialize(self):
+        component = {"type": str(self.type)}
+        if self.requires:
+            component["requires"] = self.requires
+        if self.includes:
+            component["includes"] = [x.replace("\\", "/") for x in self.includes]
+        if self.definitions:
+            component["definitions"] = self.definitions
+        if self.location:  # TODO: @prefix@
+            component["location"] = self.location
+        if self.link_location:
+            component["link_location"] = self.link_location
+        if self.link_libraries:
+            component["link_libraries"] = self.link_libraries
+        return component
+
+    @staticmethod
+    def deserialize(data):
+        comp = CPSComponent()
+        comp.type = CPSComponentType(data.get("type"))
+        comp.requires = data.get("requires")
+        comp.includes = data.get("includes")
+        comp.definitions = data.get("definitions")
+        comp.location = data.get("location")
+        comp.link_location = data.get("link_location")
+        comp.link_libraries = data.get("link_libraries")
+        return comp
+
+    @staticmethod
+    def from_cpp_info(cpp_info, pkg_type, libname=None):
+        cps_comp = CPSComponent()
+        if not libname:
+            cps_comp.definitions = cpp_info.defines
+            cps_comp.includes = [x.replace("\\", "/") for x in cpp_info.includedirs]
+
+        if not cpp_info.libs:
+            cps_comp.type = CPSComponentType.INTERFACE
+            return cps_comp
+
+        if len(cpp_info.libs) > 1 and not libname:  # Multi-lib pkg without components defined
+            cps_comp.type = CPSComponentType.INTERFACE
+            return cps_comp
+
+        libname = libname or next(iter(cpp_info.full_libs))
+        full_lib = cpp_info.full_libs[libname]
+        deduce_full_lib_info(libname, full_lib, cpp_info, pkg_type)
+        cps_comp.type = CPSComponentType.from_conan(full_lib.get("type"))
+        cps_comp.location = full_lib.get("location")
+        cps_comp.link_location = full_lib.get("link_location")
+        cps_comp.link_libraries = cpp_info.system_libs
+        required = cpp_info.requires
+        cps_comp.requires = [f":{c}" if "::" not in c else c.replace("::", ":") for c in required]
+        return cps_comp
+
+
+class CPS:
+    """ represents the CPS file for 1 package
+    """
+    def __init__(self, name=None, version=None):
+        self.name = name
+        self.version = version
+        self.default_components = []
+        self.components = {}
+        self.configurations = []
+        self.requires = []
+        # Supplemental
+        self.description = None
+        self.license = None
+        self.website = None
+
+    def serialize(self):
+        cps = {"cps_version": "0.12.0",
+               "name": self.name,
+               "version": self.version}
+
+        # Supplemental
+        for data in "license", "description", "website":
+            if getattr(self, data, None):
+                cps[data] = getattr(self, data)
+
+        if self.requires:
+            cps["requires"] = self.requires
+
+        if self.configurations:
+            cps["configurations"] = self.configurations
+
+        cps["default_components"] = self.default_components
+        cps["components"] = {}
+        for name, comp in self.components.items():
+            cps["components"][name] = comp.serialize()
+
+        return cps
+
+    @staticmethod
+    def deserialize(data):
+        cps = CPS()
+        cps.name = data.get("name")
+        cps.version = data.get("version")
+        cps.license = data.get("license")
+        cps.description = data.get("description")
+        cps.website = data.get("website")
+        cps.requires = data.get("requires")
+        cps.configurations = data.get("configurations")
+        cps.default_components = data.get("default_components")
+        cps.components = {k: CPSComponent.deserialize(v)
+                          for k, v in data.get("components", {}).items()}
+        return cps
+
+    @staticmethod
+    def from_conan(dep):
+        cps = CPS(dep.ref.name, str(dep.ref.version))
+        # supplemental
+        cps.license = dep.license
+        cps.description = dep.description
+        cps.website = dep.homepage
+
+        cps.requires = {d.ref.name: None for d in dep.dependencies.host.values()}
+        if dep.settings.get_safe("build_type"):
+            cps.configurations = [str(dep.settings.build_type).lower()]
+
+        if not dep.cpp_info.has_components:
+            if dep.cpp_info.libs and len(dep.cpp_info.libs) > 1:
+                comp = CPSComponent.from_cpp_info(dep.cpp_info, dep.package_type)  # base
+                base_name = f"_{cps.name}"
+                cps.components[base_name] = comp
+                for lib in dep.cpp_info.libs:
+                    comp = CPSComponent.from_cpp_info(dep.cpp_info, dep.package_type, lib)
+                    comp.requires.insert(0, f":{base_name}")  # dep to the common one
+                    cps.components[lib] = comp
+                cps.default_components = dep.cpp_info.libs
+                # FIXME: What if one lib is named equal to the package?
+            else:
+                # single component, called same as library
+                component = CPSComponent.from_cpp_info(dep.cpp_info, dep.package_type)
+                if not component.requires and dep.dependencies:
+                    for transitive_dep in dep.dependencies.host.items():
+                        dep_name = transitive_dep[0].ref.name
+                        component.requires.append(f"{dep_name}:{dep_name}")
+
+                # the component will be just the package name
+                cps.default_components = [f"{dep.ref.name}"]
+                cps.components[f"{dep.ref.name}"] = component
+        else:
+            sorted_comps = dep.cpp_info.get_sorted_components()
+            for comp_name, comp in sorted_comps.items():
+                component = CPSComponent.from_cpp_info(comp, dep.package_type)
+                cps.components[comp_name] = component
+            # Now by default all are default_components
+            cps.default_components = [comp_name for comp_name in sorted_comps]
+
+        return cps
+
+    def to_conan(self):
+        def strip_prefix(dirs):
+            return [d.replace("@prefix@/", "") for d in dirs]
+
+        cpp_info = CppInfo()
+        if len(self.components) == 1:
+            comp = next(iter(self.components.values()))
+            cpp_info.includedirs = strip_prefix(comp.includes)
+            cpp_info.defines = comp.definitions
+            if comp.type is CPSComponentType.ARCHIVE:
+                location = comp.location
+                location = location.replace("@prefix@/", "")
+                cpp_info.libdirs = [os.path.dirname(location)]
+                filename = os.path.basename(location)
+                basefile, ext = os.path.splitext(filename)
+                if basefile.startswith("lib") and ext != ".lib":
+                    basefile = basefile[3:]
+                cpp_info.libs = [basefile]
+                # FIXME: Missing requires
+            cpp_info.system_libs = comp.link_libraries
+        else:
+            for comp_name, comp in self.components.items():
+                cpp_comp = cpp_info.components[comp_name]
+                cpp_comp.includedirs = strip_prefix(comp.includes)
+                cpp_comp.defines = comp.definitions
+                if comp.type is CPSComponentType.ARCHIVE:
+                    location = comp.location
+                    location = location.replace("@prefix@/", "")
+                    cpp_comp.libdirs = [os.path.dirname(location)]
+                    filename = os.path.basename(location)
+                    basefile, ext = os.path.splitext(filename)
+                    if basefile.startswith("lib") and ext != ".lib":
+                        basefile = basefile[3:]
+                    cpp_comp.libs = [basefile]
+                for r in comp.requires:
+                    cpp_comp.requires.append(r[1:] if r.startswith(":") else r.replace(":", "::"))
+                cpp_comp.system_libs = comp.link_libraries
+
+        return cpp_info
+
+    def save(self, folder):
+        filename = os.path.join(folder, f"{self.name}.cps")
+        save(filename, json.dumps(self.serialize(), indent=2))
+        return filename
+
+    @staticmethod
+    def load(file):
+        contents = load(file)
+        return CPS.deserialize(json.loads(contents))
diff --git a/conan/internal/api/install/generators.py b/conan/internal/api/install/generators.py
index c385820a7..950b71485 100644
--- a/conan/internal/api/install/generators.py
+++ b/conan/internal/api/install/generators.py
@@ -31,7 +31,8 @@ _generators = {"CMakeToolchain": "conan.tools.cmake",
                "MakeDeps": "conan.tools.gnu",
                "SConsDeps": "conan.tools.scons",
                "QbsDeps": "conan.tools.qbs",
-               "QbsProfile": "conan.tools.qbs"
+               "QbsProfile": "conan.tools.qbs",
+               "CPSDeps": "conan.tools.cps"
                }
 
 
diff --git a/conan/tools/cmake/toolchain/blocks.py b/conan/tools/cmake/toolchain/blocks.py
index fffa48aa2..930d62f48 100644
--- a/conan/tools/cmake/toolchain/blocks.py
+++ b/conan/tools/cmake/toolchain/blocks.py
@@ -273,17 +273,30 @@ class CppStdBlock(Block):
     template = textwrap.dedent("""\
         # Define the C++ and C standards from 'compiler.cppstd' and 'compiler.cstd'
 
+        function(conan_modify_std_watch variable access value current_list_file stack)
+            set(conan_watched_std_variable {{ cppstd }})
+            if (${variable} STREQUAL "CMAKE_C_STANDARD")
+                set(conan_watched_std_variable {{ cstd }})
+            endif()
+            if (${access} STREQUAL "MODIFIED_ACCESS" AND NOT ${value} STREQUAL ${conan_watched_std_variable})
+                message(STATUS "Warning: Standard ${variable} value defined in conan_toolchain.cmake to ${conan_watched_std_variable} has been modified to ${value} by ${current_list_file}")
+            endif()
+            unset(conan_watched_std_variable)
+        endfunction()
+
         {% if cppstd %}
         message(STATUS "Conan toolchain: C++ Standard {{ cppstd }} with extensions {{ cppstd_extensions }}")
         set(CMAKE_CXX_STANDARD {{ cppstd }})
         set(CMAKE_CXX_EXTENSIONS {{ cppstd_extensions }})
         set(CMAKE_CXX_STANDARD_REQUIRED ON)
+        variable_watch(CMAKE_CXX_STANDARD conan_modify_std_watch)
         {% endif %}
         {% if cstd %}
         message(STATUS "Conan toolchain: C Standard {{ cstd }} with extensions {{ cstd_extensions }}")
         set(CMAKE_C_STANDARD {{ cstd }})
         set(CMAKE_C_EXTENSIONS {{ cstd_extensions }})
         set(CMAKE_C_STANDARD_REQUIRED ON)
+        variable_watch(CMAKE_C_STANDARD conan_modify_std_watch)
         {% endif %}
         """)
 
diff --git a/conan/tools/cps/__init__.py b/conan/tools/cps/__init__.py
new file mode 100644
index 000000000..64674e371
--- /dev/null
+++ b/conan/tools/cps/__init__.py
@@ -0,0 +1,1 @@
+from conan.tools.cps.cps_deps import CPSDeps
diff --git a/conan/tools/cps/cps_deps.py b/conan/tools/cps/cps_deps.py
new file mode 100644
index 000000000..578a87c01
--- /dev/null
+++ b/conan/tools/cps/cps_deps.py
@@ -0,0 +1,53 @@
+from conan.cps.cps import CPS
+from conan.tools.files import save
+
+import json
+import os
+
+
+class CPSDeps:
+    def __init__(self, conanfile):
+        self._conanfile = conanfile
+
+    def _config_name(self):
+        build_vars = ["settings.compiler", "settings.compiler.version", "settings.arch",
+                      "settings.compiler.cppstd", "settings.build_type", "options.shared"]
+        ret = []
+        for s in build_vars:
+            group, var = s.split(".", 1)
+            tmp = None
+            if group == "settings":
+                tmp = self._conanfile.settings.get_safe(var)
+            elif group == "options":
+                value = self._conanfile.options.get_safe(var)
+                if value is not None:
+                    if var == "shared":
+                        tmp = "shared" if value else "static"
+                    else:
+                        tmp = "{}_{}".format(var, value)
+            if tmp:
+                ret.append(tmp.lower())
+        return "-".join(ret)
+
+    def generate(self):
+        cps_folder = os.path.join(self._conanfile.folders.base_build, "build", "cps")
+        config_name = self._config_name()
+        folder = os.path.join(cps_folder, config_name)
+        self._conanfile.output.info(f"[CPSDeps] folder {cps_folder}")
+        deps = self._conanfile.dependencies.host.items()
+        mapping = {}
+        for _, dep in deps:
+            self._conanfile.output.info(f"[CPSDeps]: dep {dep.ref.name}")
+
+            cps_in_package = os.path.join(dep.package_folder, f"{dep.ref.name}.cps")
+            if os.path.exists(cps_in_package):
+                mapping[dep.ref.name] = cps_in_package
+                continue
+
+            cps = CPS.from_conan(dep)
+            output_file = cps.save(folder)
+            mapping[dep.ref.name] = output_file
+
+        name = f"cpsmap-{config_name}.json"
+        self._conanfile.output.info(f"Generating CPS mapping file: {name}")
+        save(self._conanfile, os.path.join(cps_folder, name), json.dumps(mapping, indent=2))
diff --git a/conans/model/build_info.py b/conans/model/build_info.py
index 536da5ca8..4aa0aee2d 100644
--- a/conans/model/build_info.py
+++ b/conans/model/build_info.py
@@ -242,6 +242,16 @@ class _Component:
     def libs(self):
         if self._libs is None:
             self._libs = []
+        if isinstance(self._libs, dict):
+            return [self._libs.keys()]  # Return a list to not break any interface
+        return self._libs
+
+    @property
+    def full_libs(self):
+        if self._libs is None:
+            self._libs = []
+        if isinstance(self._libs, list):
+            return {k: {} for k in self._libs}
         return self._libs
 
     @libs.setter


Test patch:
diff --git a/test/functional/toolchains/cmake/test_cmake_toolchain.py b/test/functional/toolchains/cmake/test_cmake_toolchain.py
index ce014b821..2bff99412 100644
--- a/test/functional/toolchains/cmake/test_cmake_toolchain.py
+++ b/test/functional/toolchains/cmake/test_cmake_toolchain.py
@@ -2033,5 +2033,11 @@ def test_cxx_version_not_overriden_if_hardcoded():
     # The compiler.cppstd will not override the CXX_STANDARD variable
     tc.run("create . -s=compiler.cppstd=11")
     assert "Conan toolchain: C++ Standard 11 with extensions OFF" in tc.out
-    # Conan says yes! But alas, the compiled code is C++17
+    assert "Warning: Standard CMAKE_CXX_STANDARD value defined in conan_toolchain.cmake to 11 has been modified to 17" in tc.out
+
+    # Even though Conan warns, the compiled code is C++17
     assert "foo/1.0: __cplusplus2017" in tc.out
+
+    tc.run("create . -s=compiler.cppstd=17")
+    assert "Conan toolchain: C++ Standard 17 with extensions OFF" in tc.out
+    assert "Warning: Standard CMAKE_CXX_STANDARD value defined in conan_toolchain.cmake to 17 has been modified to 17" not in tc.out
diff --git a/test/integration/cps/__init__.py b/test/integration/cps/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/integration/cps/test_cps.py b/test/integration/cps/test_cps.py
new file mode 100644
index 000000000..f1ce24f6c
--- /dev/null
+++ b/test/integration/cps/test_cps.py
@@ -0,0 +1,119 @@
+import json
+import os
+import textwrap
+
+from conan.test.assets.genconanfile import GenConanfile
+from conan.test.utils.tools import TestClient
+
+
+def test_cps():
+    c = TestClient()
+    c.save({"pkg/conanfile.py": GenConanfile("pkg", "0.1").with_settings("build_type")
+                                                          .with_class_attribute("license='MIT'")})
+    c.run("create pkg")
+
+    settings = "-s os=Windows -s compiler=msvc -s compiler.version=191 -s arch=x86_64"
+    c.run(f"install --requires=pkg/0.1 {settings} -g CPSDeps")
+    pkg = json.loads(c.load("build/cps/msvc-191-x86_64-release/pkg.cps"))
+    assert pkg["name"] == "pkg"
+    assert pkg["version"] == "0.1"
+    assert pkg["license"] == "MIT"
+    assert pkg["configurations"] == ["release"]
+    assert pkg["default_components"] == ["pkg"]
+    pkg_comp = pkg["components"]["pkg"]
+    assert pkg_comp["type"] == "interface"
+    mapping = json.loads(c.load("build/cps/cpsmap-msvc-191-x86_64-release.json"))
+    for _, path_cps in mapping.items():
+        assert os.path.exists(path_cps)
+
+
+def test_cps_static_lib():
+    c = TestClient()
+    c.save({"pkg/conanfile.py": GenConanfile("pkg", "0.1").with_package_file("lib/pkg.a", "-")
+                                                          .with_settings("build_type")
+            .with_package_info(cpp_info={"libs": ["pkg"]}, env_info={})})
+    c.run("create pkg")
+
+    settings = "-s os=Windows -s compiler=msvc -s compiler.version=191 -s arch=x86_64"
+    c.run(f"install --requires=pkg/0.1 {settings} -g CPSDeps")
+    pkg = json.loads(c.load("build/cps/msvc-191-x86_64-release/pkg.cps"))
+    assert pkg["name"] == "pkg"
+    assert pkg["version"] == "0.1"
+    assert pkg["configurations"] == ["release"]
+    assert pkg["default_components"] == ["pkg"]
+    pkg_comp = pkg["components"]["pkg"]
+    assert pkg_comp["type"] == "archive"
+    assert pkg_comp["location"] is not None
+
+
+def test_cps_header():
+    c = TestClient()
+    c.save({"pkg/conanfile.py": GenConanfile("pkg", "0.1").with_package_type("header-library")})
+    c.run("create pkg")
+
+    settings = "-s os=Windows -s compiler=msvc -s compiler.version=191 -s arch=x86_64"
+    c.run(f"install --requires=pkg/0.1 {settings} -g CPSDeps")
+    pkg = json.loads(c.load("build/cps/msvc-191-x86_64-release/pkg.cps"))
+    assert pkg["name"] == "pkg"
+    assert pkg["version"] == "0.1"
+    assert "configurations" not in "pkg"
+    assert pkg["default_components"] == ["pkg"]
+    pkg_comp = pkg["components"]["pkg"]
+    assert pkg_comp["type"] == "interface"
+    assert "location" not in pkg_comp
+
+
+def test_cps_in_pkg():
+    c = TestClient()
+    cps = textwrap.dedent("""\
+        {
+            "cps_version": "0.12.0",
+            "name": "zlib",
+            "version": "1.3.1",
+            "configurations": ["release"],
+            "default_components": ["zlib"],
+            "components": {
+                "zlib": {
+                    "type": "archive",
+                    "includes": ["@prefix@/include"],
+                    "location": "@prefix@/lib/zlib.a"
+                }
+            }
+        }
+        """)
+    cps = "".join(cps.splitlines())
+    conanfile = textwrap.dedent(f"""
+        import os
+        from conan.tools.files import save
+        from conan import ConanFile
+        class Pkg(ConanFile):
+            name = "zlib"
+            version = "1.3.1"
+
+            def package(self):
+                cps = '{cps}'
+                cps_path = os.path.join(self.package_folder, "zlib.cps")
+                save(self, cps_path, cps)
+
+            def package_info(self):
+                from conan.cps import CPS
+                self.cpp_info = CPS.load("zlib.cps").to_conan()
+        """)
+    c.save({"pkg/conanfile.py": conanfile})
+    c.run("create pkg")
+
+    settings = "-s os=Windows -s compiler=msvc -s compiler.version=191 -s arch=x86_64"
+    c.run(f"install --requires=zlib/1.3.1 {settings} -g CPSDeps")
+
+    mapping = json.loads(c.load("build/cps/cpsmap-msvc-191-x86_64-release.json"))
+    for _, path_cps in mapping.items():
+        assert os.path.exists(path_cps)
+
+    assert not os.path.exists(os.path.join(c.current_folder, "zlib.cps"))
+    assert not os.path.exists(os.path.join(c.current_folder, "build", "cps", "zlib.cps"))
+
+    c.run(f"install --requires=zlib/1.3.1 {settings} -g CMakeDeps")
+    cmake = c.load("zlib-release-x86_64-data.cmake")
+    assert 'set(zlib_INCLUDE_DIRS_RELEASE "${zlib_PACKAGE_FOLDER_RELEASE}/include")' in cmake
+    assert 'set(zlib_LIB_DIRS_RELEASE "${zlib_PACKAGE_FOLDER_RELEASE}/lib")'
+    assert 'set(zlib_LIBS_RELEASE zlib)' in cmake
diff --git a/test/integration/cps/test_extended_cpp_info.py b/test/integration/cps/test_extended_cpp_info.py
new file mode 100644
index 000000000..4308fd5b6
--- /dev/null
+++ b/test/integration/cps/test_extended_cpp_info.py
@@ -0,0 +1,29 @@
+import json
+import textwrap
+
+from conan.test.utils.tools import TestClient
+
+
+def test_extended_cpp_info():
+    c = TestClient()
+    conanfile = textwrap.dedent("""
+        from conan import ConanFile
+        class Pkg(ConanFile):
+            name = "pkg"
+            version = "0.1"
+            def package_info(self):
+                self.cpp_info.libs = {"mylib": {"location": "my_custom_location",
+                                                "type": "static-library"}}
+            """)
+    c.save({"conanfile.py": conanfile})
+    c.run("create .")
+
+    settings = "-s os=Windows -s compiler=msvc -s compiler.version=191 -s arch=x86_64"
+    c.run(f"install --requires=pkg/0.1 {settings} -g CPSDeps")
+    pkg = json.loads(c.load("build/cps/msvc-191-x86_64-release/pkg.cps"))
+    assert pkg["name"] == "pkg"
+    assert pkg["version"] == "0.1"
+    assert pkg["default_components"] == ["pkg"]
+    pkg_comp = pkg["components"]["pkg"]
+    assert pkg_comp["type"] == "archive"
+    assert pkg_comp["location"] == "my_custom_location"