Repository: conan-io/conan
Pull request #18028 (2025-03-26 12:21:55)

fix global_conf composition with profiles
Changelog: Bugfix: Fix ``global.conf`` precedence over profiles ``[conf]`` and order change of per-package pattern confs.
Docs: Omit

Close https://github.com/conan-io/conan/issues/13439



Patch:
diff --git a/conan/api/model/list.py b/conan/api/model/list.py
index c7149e1b2..520d221fa 100644
--- a/conan/api/model/list.py
+++ b/conan/api/model/list.py
@@ -3,9 +3,9 @@ import json
 import os
 from json import JSONDecodeError
 
+from conan.api.model import RecipeReference, PkgReference
 from conan.errors import ConanException
 from conan.internal.errors import NotFoundException
-from conan.api.model import RecipeReference, PkgReference
 from conan.internal.model.version_range import VersionRange
 from conans.client.graph.graph import RECIPE_EDITABLE, RECIPE_CONSUMER, RECIPE_PLATFORM, \
     RECIPE_VIRTUAL, BINARY_SKIP, BINARY_MISSING, BINARY_INVALID
@@ -36,6 +36,10 @@ class MultiPackagesList:
         for k, v in other.lists.items():
             self.lists.setdefault(k, PackagesList()).merge(v)
 
+    def keep_outer(self, other):
+        for namespace, other_pkg_list in other.lists.items():
+            self.lists.get(namespace, PackagesList()).keep_outer(other_pkg_list)
+
     @staticmethod
     def load(file):
         try:
@@ -55,19 +59,30 @@ class MultiPackagesList:
         return pkglist
 
     @staticmethod
-    def load_graph(graphfile, graph_recipes=None, graph_binaries=None):
+    def load_graph(graphfile, graph_recipes=None, graph_binaries=None, context=None):
         if not os.path.isfile(graphfile):
             raise ConanException(f"Graph file not found: {graphfile}")
         try:
+            base_context = context.split("-")[0] if context else None
             graph = json.loads(load(graphfile))
-            return MultiPackagesList._define_graph(graph, graph_recipes, graph_binaries)
+            mpkglist =  MultiPackagesList._define_graph(graph, graph_recipes, graph_binaries,
+                                                        context=base_context)
+            if context == "build-only":
+                host = MultiPackagesList._define_graph(graph, graph_recipes, graph_binaries,
+                                                       context="host")
+                mpkglist.keep_outer(host)
+            elif context == "host-only":
+                build = MultiPackagesList._define_graph(graph, graph_recipes, graph_binaries,
+                                                        context="build")
+                mpkglist.keep_outer(build)
+            return mpkglist
         except JSONDecodeError as e:
             raise ConanException(f"Graph file invalid JSON: {graphfile}\n{e}")
         except Exception as e:
             raise ConanException(f"Graph file broken: {graphfile}\n{e}")
 
     @staticmethod
-    def _define_graph(graph, graph_recipes=None, graph_binaries=None):
+    def _define_graph(graph, graph_recipes=None, graph_binaries=None, context=None):
         pkglist = MultiPackagesList()
         cache_list = PackagesList()
         if graph_recipes is None and graph_binaries is None:
@@ -79,6 +94,9 @@ class MultiPackagesList:
 
         pkglist.lists["Local Cache"] = cache_list
         for node in graph["graph"]["nodes"].values():
+            if context and node['context'] != context:
+                continue
+
             # We need to add the python_requires too
             python_requires = node.get("python_requires")
             if python_requires is not None:
@@ -142,6 +160,14 @@ class PackagesList:
             return d
         recursive_dict_update(self.recipes, other.recipes)
 
+    def keep_outer(self, other):
+        if not self.recipes:
+            return
+
+        for ref, info in other.recipes.items():
+            if self.recipes.get(ref, {}) == info:
+                self.recipes.pop(ref)
+
     def split(self):
         """
         Returns a list of PackageList, splitted one per reference.
diff --git a/conan/cli/commands/list.py b/conan/cli/commands/list.py
index da032afc9..addfb6848 100644
--- a/conan/cli/commands/list.py
+++ b/conan/cli/commands/list.py
@@ -9,7 +9,6 @@ from conan.cli.formatters.list import list_packages_html
 from conan.errors import ConanException
 from conans.util.dates import timestamp_to_str
 
-
 # Keep them so we don't break other commands that import them, but TODO: Remove later
 remote_color = Color.BRIGHT_BLUE
 recipe_name_color = Color.GREEN
@@ -225,6 +224,9 @@ def list(conan_api: ConanAPI, parser, *args):
     parser.add_argument("-g", "--graph", help="Graph json file")
     parser.add_argument("-gb", "--graph-binaries", help="Which binaries are listed", action="append")
     parser.add_argument("-gr", "--graph-recipes", help="Which recipes are listed", action="append")
+    parser.add_argument("-gc", "--graph-context", help="Filter the results by the given context",
+                        default=None, action=OnceArgument,
+                        choices=["build", "host", "build-only", "host-only"])
     parser.add_argument('--lru', default=None, action=OnceArgument,
                         help="List recipes and binaries that have not been recently used. Use a"
                              " time limit like --lru=5d (days) or --lru=4w (weeks),"
@@ -248,7 +250,8 @@ def list(conan_api: ConanAPI, parser, *args):
 
     if args.graph:
         graphfile = make_abs_path(args.graph)
-        pkglist = MultiPackagesList.load_graph(graphfile, args.graph_recipes, args.graph_binaries)
+        pkglist = MultiPackagesList.load_graph(graphfile, args.graph_recipes, args.graph_binaries,
+                                               context=args.graph_context)
     else:
         ref_pattern = ListPattern(args.pattern, rrev=None, prev=None)
         if not ref_pattern.package_id and (args.package_query or args.filter_profile or
diff --git a/conan/internal/model/conf.py b/conan/internal/model/conf.py
index 335d7c08c..3e3e95959 100644
--- a/conan/internal/model/conf.py
+++ b/conan/internal/model/conf.py
@@ -610,18 +610,16 @@ class ConfDefinition:
         else:
             self._pattern_confs[pattern] = conf
 
-    def rebase_conf_definition(self, other):
+    def rebase_conf_definition(self, global_conf):
         """
         for taking the new global.conf and composing with the profile [conf]
-        :type other: ConfDefinition
+        :type global_conf: ConfDefinition
         """
-        for pattern, conf in other._pattern_confs.items():
-            new_conf = conf.filter_user_modules()  # Creates a copy, filtered
-            existing = self._pattern_confs.get(pattern)
-            if existing:
-                existing.compose_conf(new_conf)
-            else:
-                self._pattern_confs[pattern] = new_conf
+        result = ConfDefinition()
+        result._pattern_confs = global_conf._pattern_confs.copy()
+        result.update_conf_definition(self)
+        self._pattern_confs = result._pattern_confs
+        return
 
     def update(self, key, value, profile=False, method="define"):
         """
diff --git a/conans/client/graph/graph.py b/conans/client/graph/graph.py
index b5037c271..1c8efc7fd 100644
--- a/conans/client/graph/graph.py
+++ b/conans/client/graph/graph.py
@@ -418,13 +418,6 @@ class DepsGraph(object):
 
         return result
 
-    def build_time_nodes(self):
-        """ return all the nodes in the graph that are build-requires (either directly or
-        transitively). Nodes that are both in requires and build_requires will not be returned.
-        This is used just for output purposes, printing deps, HTML graph, etc.
-        """
-        return [n for n in self.nodes if n.context == CONTEXT_BUILD]
-
     def report_graph_error(self):
         if self.error:
             raise self.error


Test patch:
diff --git a/test/integration/command/list/test_combined_pkglist_flows.py b/test/integration/command/list/test_combined_pkglist_flows.py
index 659069232..e1e8d281e 100644
--- a/test/integration/command/list/test_combined_pkglist_flows.py
+++ b/test/integration/command/list/test_combined_pkglist_flows.py
@@ -382,3 +382,49 @@ class TestListRemove:
         client.run(f"list *:* {remote}")
         assert "zli/1.0.0" in client.out
         assert "zlib/1.0.0@user/channel" in client.out
+
+
+class TestListGraphContext:
+
+    @pytest.mark.parametrize("context, refs, not_refs", [
+        ("build", ["cmake/1.0", "m4/1.0", "protobuf/1.0"], []),
+        ("build-only", ["cmake/1.0", "m4/1.0"], ["protobuf/1.0"]),
+        ("host", ["zlib/1.0", "protobuf/1.0"], []),
+        ("host-only", ["zlib/1.0"], ["protobuf/1.0"])
+    ])
+    def test_list_graph_context(self, context, refs, not_refs):
+        c = TestClient(default_server_user=True, light=True)
+        c.save({
+            "zlib/conanfile.py": GenConanfile("zlib", "1.0").with_settings("os"),
+            "cmake/conanfile.py": GenConanfile("cmake", "1.0").with_settings("os"),
+            "m4/conanfile.py": GenConanfile("m4", "1.0").with_settings("os"),
+            "protobuf/conanfile.py": GenConanfile("protobuf", "1.0").with_settings("os"),
+            "app/conanfile.py": GenConanfile("app", "1.0").with_settings("os")
+            .with_requires("zlib/1.0", "protobuf/1.0")
+            .with_tool_requires("cmake/1.0", "m4/1.0", "protobuf/1.0")})
+        c.run("create zlib")
+        c.run("create protobuf")
+        c.run("create cmake")
+        c.run("create m4")
+        c.run("create app --format=json", redirect_stdout="graph.json")
+        # Now, let's filter the pkgs
+        c.run(f"list --graph=graph.json --graph-context={context} --format=json",
+              redirect_stdout="pkglist.json")
+        content = json.loads(c.load("pkglist.json"))
+        for ref in refs:
+            assert content["Local Cache"][ref]["revisions"]
+        for ref in not_refs:
+            assert content["Local Cache"].get(ref) is None
+        # Let's upload all the packages
+        c.run(f"list --graph=graph.json --format=json", redirect_stdout="pkglist.json")
+        c.run("upload --list=pkglist.json -r=default")
+        c.run("remove * -c")
+        c.run("create app --format=json", redirect_stdout="graph.json")
+        # Now, let's filter the pkgs
+        c.run(f"list --graph=graph.json --graph-context={context} -r=default --format=json",
+              redirect_stdout="pkglist.json")
+        content = json.loads(c.load("pkglist.json"))
+        for ref in refs:
+            assert content["default"][ref]["revisions"]
+        for ref in not_refs:
+            assert content["default"].get(ref) is None
diff --git a/test/integration/graph/test_skip_build.py b/test/integration/graph/test_skip_build.py
index 44e74e0fb..3eb419a50 100644
--- a/test/integration/graph/test_skip_build.py
+++ b/test/integration/graph/test_skip_build.py
@@ -1,3 +1,4 @@
+import re
 import textwrap
 
 from conan.test.assets.genconanfile import GenConanfile
@@ -46,3 +47,38 @@ def test_graph_skip_build_test():
     c.run("install app -c tools.graph:skip_build=True --build=pkg/*", assert_error=True)
     assert "ERROR: Package pkg/1.0 skipped its test/tool requires with tools.graph:skip_build, " \
            "but was marked to be built " in c.out
+
+
+def test_skip():
+    # https://github.com/conan-io/conan/issues/13439
+    c = TestClient()
+    global_conf = textwrap.dedent("""
+        tools.graph:skip_test=True
+        tools.build:skip_test=True
+        """)
+    c.save_home({"global.conf": global_conf})
+    c.save({"pkga/conanfile.py": GenConanfile("pkga", "1.0.0"),
+            "pkgb/conanfile.py": GenConanfile("pkgb", "1.0.0").with_test_requires("pkga/1.0.0"),
+            "pkgc/conanfile.py": GenConanfile("pkgc", "1.0.0").with_test_requires("pkgb/1.0.0")})
+    c.run("create pkga")
+    c.run("create pkgb")
+
+    # Always skipped
+    c.run("install pkgc")
+    # correct, pkga and pkgb are not in the output at all, they have been skipped
+    assert "pkga" not in c.out
+    assert "pkgb" not in c.out
+
+    # not skipping test-requires
+    c.run("install pkgc -c tools.graph:skip_test=False")
+    # correct, pkga and pkgb are not skipped now
+    assert "pkga" in c.out
+    assert "pkgb" in c.out
+    # but pkga binary is not really necessary
+    assert re.search(r"Skipped binaries(\s*)pkga/1.0.0", c.out)
+
+    # skipping all but the current one
+    c.run("install pkgc -c &:tools.graph:skip_test=False")
+    # correct, only pkga  is skipped now
+    assert "pkga" not in c.out
+    assert "pkgb" in c.out
diff --git a/test/unittests/model/test_conf.py b/test/unittests/model/test_conf.py
index 25dfbdedf..a20b132b0 100644
--- a/test/unittests/model/test_conf.py
+++ b/test/unittests/model/test_conf.py
@@ -76,6 +76,71 @@ def test_conf_rebase(conf_definition):
     assert c.dumps() == result
 
 
+def test_conf_rebase_patterns():
+    text = textwrap.dedent("""\
+        user.company:conf1=value1
+        user.company:conf2=[1, 2, 3]
+        &:user.company:conf1=value2
+        &:user.company:conf2+=[4, 5]
+        """)
+    global_conf = ConfDefinition()
+    global_conf.loads(text)
+    text = textwrap.dedent("""\
+        user.company:conf1=newvalue1
+        user.company:conf2=[6, 7]
+        """)
+    profile = ConfDefinition()
+    profile.loads(text)
+    profile.rebase_conf_definition(global_conf)
+    # TODO: at the moment dumps() doesn't print += append
+    result = textwrap.dedent("""\
+        user.company:conf1=newvalue1
+        user.company:conf2=[6, 7]
+        &:user.company:conf1=value2
+        &:user.company:conf2=[4, 5]
+    """)
+    assert profile.dumps() == result
+
+    text = textwrap.dedent("""\
+        &:user.company:conf1=newvalue1
+        &:user.company:conf2=[6, 7]
+        """)
+    profile = ConfDefinition()
+    profile.loads(text)
+    profile.rebase_conf_definition(global_conf)
+    result = textwrap.dedent("""\
+        user.company:conf1=value1
+        user.company:conf2=[1, 2, 3]
+        &:user.company:conf1=newvalue1
+        &:user.company:conf2=[6, 7]
+       """)
+    assert profile.dumps() == result
+
+    text = "user.company:conf2+=[6, 7]"
+    profile = ConfDefinition()
+    profile.loads(text)
+    profile.rebase_conf_definition(global_conf)
+    result = textwrap.dedent("""\
+        user.company:conf1=value1
+        user.company:conf2=[1, 2, 3, 6, 7]
+        &:user.company:conf1=value2
+        &:user.company:conf2=[4, 5]
+        """)
+    assert profile.dumps() == result
+
+    text = "&:user.company:conf2=+[6, 7]"
+    profile = ConfDefinition()
+    profile.loads(text)
+    profile.rebase_conf_definition(global_conf)
+    result = textwrap.dedent("""\
+        user.company:conf1=value1
+        user.company:conf2=[1, 2, 3]
+        &:user.company:conf1=value2
+        &:user.company:conf2=[6, 7, 4, 5]
+        """)
+    assert profile.dumps() == result
+
+
 def test_conf_error_per_package():
     text = "*:core:non_interactive=minimal"
     c = ConfDefinition()