Repository: conan-io/conan
Pull request: [feat] Recipes declare what they provide (and this can create a conflict)
Changelog: Feature: Add `provides` attribute to `ConanFile`: recipes can declare what they provide and Conan will fail if several recipes provide the same functionality (ODR violation).
Docs: https://github.com/conan-io/docs/pull/1786

If nothing is provided explicitly, the `provides` attribute takes one single value equal to the reference name. Two recipes providing the same functionality **in the same context** (public closure) will produce an error.

Q: Error or warning?

Q: Transitive private requirements, is it an ODR violation?

close https://github.com/conan-io/conan/issues/7252

Problem statement:
[feature] Declare what a recipe 'provides'
Add a `provides` attribute to the ConanFile class, it will list other package names that define the same functionality. Some examples:
 * `libjpeg`, `libjpeg-turbo`, `mozjpeg` are different implementations of the same functionality and break the ODR principle. 
 * deprecated recipes like `cpp-taskflow` that takes the new name `taskflow`
 * frameworks and individual libraries like a monolith `boost` and a modular one

All of them will introduce duplicated functionality in the graph (probably linking problems) that we want to avoid.

Recipes `mozjpeg` and `libjpeg-turbo` will contain a `provides = "libjpeg"` attribute and Conan will raise if both are present in the graph (any alternative and the _master_ one).

The workaround for this situation is to replace the requirement modifying one recipe in the middle to change the requirements, but eventually it will remove all the alternative implementations from the graph. **The actual solution would be to create a "proxy/virtual" recipe that, based on an option, will choose one of the implementations**, and all the recipes should require that proxy recipe instead of the actual jpeg implementation. Conan built-in functionality ensures that there is only one instance of the proxy recipe and there will be only one implementation of the actual functionality.

We should provide a POC together with the feature implementation to check it actually works.

Hints from issue discussion:
debian documentation: https://www.debian.org/doc/debian-policy/ch-relationships.html#virtual-packages-provides
more cases:
- [LibreSSL](https://www.libressl.org/), [BoringSSL](https://boringssl.googlesource.com/boringssl/) and [OpenSSL](https://www.openssl.org/)
- [libav](https://libav.org/) and [ffmpeg](https://ffmpeg.org/)
- [MariaDB client](https://downloads.mariadb.org/client-native/) and [MySQL client](https://dev.mysql.com/downloads/c-api/)
Reading some comments, I wonder if the value for the `provides` should be the name of the _master_ recipe or the name of the "virtual/proxy" one. With the name of the "virtual/proxy", Conan can show a meaningful message with the requirement you should use to fix the conflict.

Commit message: textwrap.indent not available in py27

Patch to review:
diff --git a/conans/client/graph/graph_manager.py b/conans/client/graph/graph_manager.py
index f880e0ea9ee..bbc8c83e759 100644
--- a/conans/client/graph/graph_manager.py
+++ b/conans/client/graph/graph_manager.py
@@ -1,6 +1,6 @@
 import fnmatch
 import os
-from collections import OrderedDict
+from collections import OrderedDict, defaultdict
 
 from conans.client.conanfile.configure import run_configure_method
 from conans.client.generators.text import TXTGenerator
@@ -114,8 +114,14 @@ def load_graph(self, reference, create_reference, graph_info, build_mode, check_
         """ main entry point to compute a full dependency graph
         """
         root_node = self._load_root_node(reference, create_reference, graph_info)
-        return self._resolve_graph(root_node, graph_info, build_mode, check_updates, update, remotes,
-                                   recorder, apply_build_requires=apply_build_requires)
+        deps_graph = self._resolve_graph(root_node, graph_info, build_mode, check_updates, update,
+                                         remotes, recorder,
+                                         apply_build_requires=apply_build_requires)
+
+        # Run some validations once the graph is built
+        self._validate_graph_provides(deps_graph)
+
+        return deps_graph
 
     def _load_root_node(self, reference, create_reference, graph_info):
         """ creates the first, root node of the graph, loading or creating a conanfile
@@ -294,15 +300,15 @@ def _recurse_build_requires(self, graph, builder, check_updates,
                 continue
             # Packages with PACKAGE_ID_UNKNOWN might be built in the future, need build requires
             if (node.binary not in (BINARY_BUILD, BINARY_EDITABLE, BINARY_UNKNOWN)
-                    and node.recipe != RECIPE_CONSUMER):
+                and node.recipe != RECIPE_CONSUMER):
                 continue
             package_build_requires = self._get_recipe_build_requires(node.conanfile, default_context)
             str_ref = str(node.ref)
             new_profile_build_requires = []
             for pattern, build_requires in profile_build_requires.items():
                 if ((node.recipe == RECIPE_CONSUMER and pattern == "&") or
-                        (node.recipe != RECIPE_CONSUMER and pattern == "&!") or
-                        fnmatch.fnmatch(str_ref, pattern)):
+                    (node.recipe != RECIPE_CONSUMER and pattern == "&!") or
+                    fnmatch.fnmatch(str_ref, pattern)):
                     for build_require in build_requires:
                         br_key = (build_require.name, default_context)
                         if br_key in package_build_requires:  # Override defined
@@ -363,6 +369,28 @@ def _load_graph(self, root_node, check_updates, update, build_mode, remotes,
 
         return graph
 
+    @staticmethod
+    def _validate_graph_provides(deps_graph):
+        # Check that two different nodes are not providing the same (ODR violation)
+        for node in deps_graph.nodes:
+            provides = defaultdict(list)
+            if node.conanfile.provides is not None:  # consumer conanfile doesn't initialize
+                for it in node.conanfile.provides:
+                    provides[it].append(node)
+
+            for item in filter(lambda u: u.context == CONTEXT_HOST, node.public_closure):
+                for it in item.conanfile.provides:
+                    provides[it].append(item)
+
+            # Check (and report) if any functionality is provided by several different recipes
+            conflicts = [it for it in provides.keys() if len(provides[it]) > 1]
+            if conflicts:
+                msg_lines = ["At least two recipes provides the same functionality:"]
+                for it in conflicts:
+                    nodes_str = "', '".join([n.conanfile.display_name for n in provides[it]])
+                    msg_lines.append(" - '{}' provided by '{}'".format(it, nodes_str))
+                raise ConanException('\n'.join(msg_lines))
+
 
 def load_deps_info(current_path, conanfile, required):
     def get_forbidden_access_object(field_name):
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
index 2e72cf948c1..72c00f21037 100644
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -18,6 +18,7 @@
 from conans.util.conan_v2_mode import CONAN_V2_MODE_ENVVAR
 from conans.util.conan_v2_mode import conan_v2_behavior
 from conans.util.env_reader import get_env
+from conans.util.misc import make_tuple
 
 
 def create_options(conanfile):
@@ -129,6 +130,8 @@ class ConanFile(object):
     options = None
     default_options = None
 
+    provides = None
+
     def __init__(self, output, runner, display_name="", user=None, channel=None):
         # an output stream (writeln, info, warn error)
         self.output = ScopedOutput(display_name, output)
@@ -170,6 +173,9 @@ def initialize(self, settings, env):
         # user specified env variables
         self._conan_env_values = env.copy()  # user specified -e
 
+        # Recipe provides its own name if nothing else is defined
+        self.provides = make_tuple(self.provides or self.name)
+
     @property
     def env(self):
         """Apply the self.deps_env_info into a copy of self._conan_env_values (will prioritize the
diff --git a/conans/test/functional/graph_lock/graph_lock_test.py b/conans/test/functional/graph_lock/graph_lock_test.py
index 01c0078ea45..ad8cd94afec 100644
--- a/conans/test/functional/graph_lock/graph_lock_test.py
+++ b/conans/test/functional/graph_lock/graph_lock_test.py
@@ -368,11 +368,11 @@ def export_pkg_test(self):
 class GraphLockBuildRequireVersionRangeTest(GraphLockVersionRangeTest):
     consumer = GenConanfile().with_name("PkgB").with_version("0.1")\
                              .with_build_require_plain("PkgA/[>=0.1]@user/channel")
-    pkg_b_revision = "b6f49e5ba6dd3d64af09a2f288e71330"
+    pkg_b_revision = "b7338f650cc61f8e0ad0285cd1b77b92"
     pkg_b_id = "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
-    pkg_b_package_revision = "#33a5634bbd9ec26b369d3900d91ea9a0"
-    modified_pkg_b_revision = "62a38c702f14cb9de952bb22b40d6ecc"
-    modified_pkg_b_package_revision = "#b7850e289326d594fbc10088d55f5259"
+    pkg_b_package_revision = "#a903a10014693fc381c6bc8e6c91bf4a"
+    modified_pkg_b_revision = "d4adbdf5c7426cfa4cdca63bb96984b6"
+    modified_pkg_b_package_revision = "#fe05c10f59b44a0ae5de0269c5a4ca3e"
 
 
 class GraphLockVersionRangeInfoTest(GraphLockVersionRangeTest):
@@ -603,7 +603,7 @@ def build_order_build_requires_test(self):
                          ":5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9", ca[1])
         level1 = jsonbo[1]
         cb = level1[0]
-        self.assertEqual("CB/1.0@user/channel#29352c82c9c6b7d1be85524ef607f77f"
+        self.assertEqual("CB/1.0@user/channel#5a90ed235bbda74b863e4045d41e5703"
                          ":5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9", cb[1])
 
     def consumer_build_order_test(self):
@@ -705,7 +705,7 @@ def test_not_locked_build_requires(self):
 
         # Building the graphlock we get the message
         client.run("graph lock variant.py")
-        fmpe = "ffmpeg/1.0#5522e93e2abfbd455e6211fe4d0531a2:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
+        fmpe = "ffmpeg/1.0#917812a86b6c17f109d7537f3c5102e4:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
         font = "fontconfig/1.0#f3367e0e7d170aa12abccb175fee5f97:"\
                "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
         harf = "harfbuzz/1.0#3172f5e84120f235f75f8dd90fdef84f:"\
@@ -761,7 +761,7 @@ def test_build_requires_should_be_locked(self):
 
         # Building the graphlock we get the message
         client.run("graph lock variant.py --build")
-        fmpe = "ffmpeg/1.0#5522e93e2abfbd455e6211fe4d0531a2:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
+        fmpe = "ffmpeg/1.0#917812a86b6c17f109d7537f3c5102e4:5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
         font = "fontconfig/1.0#f3367e0e7d170aa12abccb175fee5f97:"\
                "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9"
         harf = "harfbuzz/1.0#3172f5e84120f235f75f8dd90fdef84f:"\
diff --git a/conans/test/functional/recipe_provides/__init__.py b/conans/test/functional/recipe_provides/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/conans/test/functional/recipe_provides/test_build_requires_conflicts.py b/conans/test/functional/recipe_provides/test_build_requires_conflicts.py
new file mode 100644
index 00000000000..612559cfddc
--- /dev/null
+++ b/conans/test/functional/recipe_provides/test_build_requires_conflicts.py
@@ -0,0 +1,81 @@
+import unittest
+
+from parameterized import parameterized
+
+from conans.test.utils.tools import TestClient, GenConanfile
+
+
+class BuildRequiresTestCase(unittest.TestCase):
+
+    @parameterized.expand([(True,), (False,)])
+    def test_build_require_lib(self, use_single_profile):
+        t = TestClient()
+        t.save({'br_lib.py': GenConanfile("br_lib", "v1").with_provides("libjpeg"),
+                'br.py': GenConanfile("br", "v1").with_require_plain("br_lib/v1"),
+                'app.py': GenConanfile("app", "v1").with_build_require_plain("br/v1")
+                                                   .with_provides("libjpeg")})
+        t.run("create br_lib.py")
+        t.run("create br.py")
+        if use_single_profile:
+            t.run("install app.py", assert_error=True)
+            self.assertIn(" - 'libjpeg' provided by 'app.py (app/v1)', 'br_lib/v1'", t.out)
+        else:
+            t.run("install app.py --profile:host=default --profile:build=default")
+
+    @parameterized.expand([(True,), (False,)])
+    def test_build_require_host(self, use_single_profile):
+        t = TestClient()
+        t.save({'br_lib.py': GenConanfile("br_lib", "v1").with_provides("libjpeg"),
+                'br.py': GenConanfile("br", "v1").with_require_plain("br_lib/v1"),
+                'app.py': GenConanfile("app", "v1").with_build_require_plain("br/v1",
+                                                                             force_host_context=True)
+                                                   .with_provides("libjpeg")})
+        t.run("create br_lib.py")
+        t.run("create br.py")
+        if use_single_profile:
+            t.run("install app.py", assert_error=True)
+        else:
+            t.run("install app.py --profile:host=default --profile:build=default", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'app.py (app/v1)', 'br_lib/v1'", t.out)
+
+    @parameterized.expand([(True,), (False,)])
+    def test_build_require_host_transitive(self, use_single_profile):
+        t = TestClient()
+        t.save({'br.py': GenConanfile("br", "v1").with_provides("libjpeg"),
+                'lib.py': GenConanfile("lib", "v1").with_build_require_plain("br/v1",
+                                                                             force_host_context=True),
+                'app.py': GenConanfile("app", "v1").with_require_plain("lib/v1")
+                                                   .with_provides("libjpeg")})
+        t.run("export br.py")
+        t.run("export lib.py")
+        if use_single_profile:
+            t.run("install app.py --build")
+        else:
+            t.run("install app.py --profile:host=default --profile:build=default --build")
+
+    @parameterized.expand([(True,), (False,)])
+    def test_build_require_branches(self, use_single_profile):
+        t = TestClient()
+        t.save({'br_lhs.py': GenConanfile("br_lhs", "v1").with_provides("libjpeg"),
+                'br_rhs.py': GenConanfile("br_rhs", "v1").with_provides("libjpeg"),
+                'app.py': GenConanfile("app", "v1").with_build_require_plain("br_lhs/v1")
+                                                   .with_build_require_plain("br_rhs/v1")})
+        t.run("create br_lhs.py")
+        t.run("create br_rhs.py")
+        if use_single_profile:
+            t.run("install app.py", assert_error=True)
+            self.assertIn(" - 'libjpeg' provided by 'br_lhs/v1', 'br_rhs/v1'", t.out)
+        else:
+            t.run("install app.py --profile:host=default --profile:build=default")
+
+    def test_build_require_of_build_require(self):
+        # Only makes sense for two profiles
+        t = TestClient()
+        t.save({'br_nested.py': GenConanfile("br_nested", "v1").with_provides("libjpeg"),
+                'br.py': GenConanfile("br", "v1").with_provides("libjpeg")
+                                                 .with_build_require_plain("br_nested/v1"),
+                'app.py': GenConanfile("app", "v1").with_provides("libjpeg")
+                                                   .with_build_require_plain("br/v1")})
+        t.run("export br_nested.py")
+        t.run("export br.py")
+        t.run("install app.py --profile:host=default --profile:build=default --build")
diff --git a/conans/test/functional/recipe_provides/test_requires_conflicts.py b/conans/test/functional/recipe_provides/test_requires_conflicts.py
new file mode 100644
index 00000000000..224f4c5bf54
--- /dev/null
+++ b/conans/test/functional/recipe_provides/test_requires_conflicts.py
@@ -0,0 +1,58 @@
+import unittest
+import textwrap
+import unittest
+
+from jinja2 import Template
+
+from conans.test.utils.tools import TestClient, GenConanfile
+
+
+class RequiresConflictsTestCase(unittest.TestCase):
+    header_only = Template(textwrap.dedent("""
+        from conans import ConanFile
+
+        class Recipe(ConanFile):
+            requires = '{{ requires|join("', '") }}'
+            def package_info(self):
+                self.info.header_only()
+    """))
+
+    def test_conflict_requirement(self):
+        t = TestClient()
+        t.save({'requires.py': GenConanfile("req", "v1").with_provides("libjpeg"),
+                'app.py': GenConanfile().with_provides("libjpeg")
+               .with_require_plain("req/v1")})
+        t.run("export requires.py")
+        t.run("install app.py app/version@", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'app.py (app/version)', 'req/v1'", t.out)
+
+    def test_conflict_transitive(self):
+        t = TestClient()
+        t.save({'top.py': GenConanfile("top", "v1").with_provides("libjpeg"),
+                'middle.py': self.header_only.render(requires=['top/v1', ]),
+                'app.py': GenConanfile().with_provides("libjpeg")
+                                        .with_require_plain("middle/v1")})
+        t.run("export top.py")
+        t.run("export middle.py middle/v1@")
+        t.run("install app.py app/version@", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'app.py (app/version)', 'top/v1'", t.out)
+
+    def test_conflict_branches(self):
+        t = TestClient()
+        t.save({'lhs.py': GenConanfile("lhs", "v1").with_provides("libjpeg"),
+                'rhs.py': GenConanfile("rhs", "v1").with_provides("libjpeg"),
+                'app.py': GenConanfile().with_require_plain("lhs/v1").with_require_plain("rhs/v1")})
+        t.run("export lhs.py")
+        t.run("export rhs.py")
+        t.run("install app.py app/version@", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'lhs/v1', 'rhs/v1'", t.out)
+
+    def test_conflict_branches_txt(self):
+        t = TestClient()
+        t.save({'lhs.py': GenConanfile("lhs", "v1").with_provides("libjpeg"),
+                'rhs.py': GenConanfile("rhs", "v1").with_provides("libjpeg"),
+                'conanfile.txt': "[requires]\nlhs/v1\nrhs/v1"})
+        t.run("export lhs.py")
+        t.run("export rhs.py")
+        t.run("install conanfile.txt", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'lhs/v1', 'rhs/v1'", t.out)
diff --git a/conans/test/functional/recipe_provides/test_requires_private.py b/conans/test/functional/recipe_provides/test_requires_private.py
new file mode 100644
index 00000000000..f20919ffec8
--- /dev/null
+++ b/conans/test/functional/recipe_provides/test_requires_private.py
@@ -0,0 +1,27 @@
+import unittest
+
+from conans.test.utils.tools import TestClient, GenConanfile
+
+
+class RequiresPrivateTestCase(unittest.TestCase):
+
+    def test_conflict_branches_private(self):
+        t = TestClient()
+        t.save({'lhs.py': GenConanfile("lhs", "v1").with_provides("libjpeg"),
+                'rhs.py': GenConanfile("rhs", "v1").with_provides("libjpeg"),
+                'app.py': GenConanfile().with_require_plain("lhs/v1", private=True)
+                                        .with_require_plain("rhs/v1", private=True)})
+        t.run("export lhs.py")
+        t.run("export rhs.py")
+        t.run("install app.py app/version@", assert_error=True)
+        self.assertIn(" - 'libjpeg' provided by 'lhs/v1', 'rhs/v1'", t.out)
+
+    def test_conflict_transitive(self):
+        t = TestClient()
+        t.save({'top.py': GenConanfile("top", "v1").with_provides("libjpeg"),
+                'middle.py': GenConanfile("middle", "v1").with_require_plain("top/v1", private=True),
+                'app.py': GenConanfile().with_provides("libjpeg")
+                                        .with_require_plain("middle/v1", private=True)})
+        t.run("export top.py")
+        t.run("export middle.py middle/v1@")
+        t.run("install app.py app/version@ --build=missing")
diff --git a/conans/test/unittests/util/misc/__init__.py b/conans/test/unittests/util/misc/__init__.py
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/conans/test/unittests/util/misc/test_make_tuple.py b/conans/test/unittests/util/misc/test_make_tuple.py
new file mode 100644
index 00000000000..2d29c5c9112
--- /dev/null
+++ b/conans/test/unittests/util/misc/test_make_tuple.py
@@ -0,0 +1,22 @@
+import unittest
+
+from conans.util.misc import make_tuple
+
+
+class MakeTupleTestCase(unittest.TestCase):
+    def test_corner_cases(self):
+        self.assertIsNone(make_tuple(None))
+        self.assertTupleEqual(make_tuple("one"), ("one",))
+
+    def test_iterable(self):
+        self.assertTupleEqual(make_tuple([1, 2, 3]), (1, 2, 3))
+        self.assertTupleEqual(make_tuple(("one", "two")), ("one", "two"))
+        self.assertTupleEqual(make_tuple({1: "a", 2: "b", 3: "c"}.keys()), (1, 2, 3))
+        self.assertTupleEqual(make_tuple({1: "a", 2: "b", 3: "c"}.values()), ("a", "b", "c"))
+
+    def test_generator(self):
+        def items():
+            for i in [1, 2, 3]:
+                yield i
+
+        self.assertTupleEqual(make_tuple(items()), (1, 2, 3))
diff --git a/conans/test/utils/genconanfile.py b/conans/test/utils/genconanfile.py
index b7315fc3693..9df9794df69 100644
--- a/conans/test/utils/genconanfile.py
+++ b/conans/test/utils/genconanfile.py
@@ -20,6 +20,7 @@ def __init__(self, name=None, version=None):
         self._options = {}
         self._generators = []
         self._default_options = {}
+        self._provides = []
         self._package_files = {}
         self._package_files_env = {}
         self._package_files_link = {}
@@ -41,6 +42,10 @@ def with_version(self, version):
         self._version = version
         return self
 
+    def with_provides(self, provides):
+        self._provides.append(provides)
+        return self
+
     def with_revision_mode(self, revision_mode):
         self._revision_mode = revision_mode
         return self
@@ -67,11 +72,11 @@ def with_requirement_plain(self, ref_str, private=False, override=False):
         self._requirements.append((ref_str, private, override))
         return self
 
-    def with_build_require(self, ref):
-        return self.with_build_require_plain(ref.full_str())
+    def with_build_require(self, ref, force_host_context=False):
+        return self.with_build_require_plain(ref.full_str(), force_host_context=force_host_context)
 
-    def with_build_require_plain(self, ref_str):
-        self._build_requires.append(ref_str)
+    def with_build_require_plain(self, ref_str, force_host_context=False):
+        self._build_requires.append((ref_str, force_host_context))
         return self
 
     def with_import(self, i):
@@ -137,6 +142,13 @@ def _version_line(self):
             return ""
         return "version = '{}'".format(self._version)
 
+    @property
+    def _provides_line(self):
+        if not self._provides:
+            return ""
+        line = ", ".join('"{}"'.format(provide) for provide in self._provides)
+        return "provides = {}".format(line)
+
     @property
     def _scm_line(self):
         if not self._scm:
@@ -182,12 +194,15 @@ def _default_options_line(self):
         return tmp
 
     @property
-    def _build_requires_line(self):
+    def _build_requirements_method(self):
         if not self._build_requires:
             return ""
-        line = ", ".join(['"{}"'.format(r) for r in self._build_requires])
-        tmp = "build_requires = %s" % line
-        return tmp
+
+        lines = []
+        for ref, force_host_context in self._build_requires:
+            force_host = ", force_host_context=True" if force_host_context else ""
+            lines.append('        self.build_requires("{}"{})'.format(ref, force_host))
+        return "def build_requirements(self):\n{}\n".format("\n".join(lines))
 
     @property
     def _requires_line(self):
@@ -304,14 +319,16 @@ def __repr__(self):
             ret.append("    {}".format(self._name_line))
         if self._version_line:
             ret.append("    {}".format(self._version_line))
+        if self._provides_line:
+            ret.append("    {}".format(self._provides_line))
         if self._generators_line:
             ret.append("    {}".format(self._generators_line))
         if self._requires_line:
             ret.append("    {}".format(self._requires_line))
         if self._requirements_method:
             ret.append("    {}".format(self._requirements_method))
-        if self._build_requires_line:
-            ret.append("    {}".format(self._build_requires_line))
+        if self._build_requirements_method:
+            ret.append("    {}".format(self._build_requirements_method))
         if self._scm:
             ret.append("    {}".format(self._scm_line))
         if self._revision_mode_line:
diff --git a/conans/util/misc.py b/conans/util/misc.py
new file mode 100644
index 00000000000..1b3b3bc54b8
--- /dev/null
+++ b/conans/util/misc.py
@@ -0,0 +1,18 @@
+import six
+
+
+def make_tuple(value):
+    """ Converts the value into a tuple if the value is an iterable with the following exceptions:
+        * a `None` value will return `None`
+        * a string value will return a tuple with the string as the unique member
+    """
+    if value is None:
+        return None
+
+    if isinstance(value, six.string_types):
+        return value,
+
+    if isinstance(value, six.moves.collections_abc.Iterable):
+        return tuple(value)
+    else:
+        return value,


Code review comments:
diff_hunk: @@ -363,6 +369,28 @@ def _load_graph(self, root_node, check_updates, update, build_mode, remotes,
 
         return graph
 
+    @staticmethod
+    def _validate_graph_provides(deps_graph):
+        # Check that two different nodes are not providing the same (ODR violation)
+        for node in deps_graph.nodes:
+            provides = defaultdict(list)
+            if node.conanfile.provides is not None:  # consumer conanfile doesn't initialize
+                for it in node.conanfile.provides:
+                    provides[it].append(node)
+
+            for item in filter(lambda u: u.context == CONTEXT_HOST, node.public_closure):
+                for it in item.conanfile.provides:
+                    provides[it].append(item)
+
+            # Check (and report) if any functionality is provided by several different recipes
+            conflicts = [it for it in provides.keys() if len(provides[it]) > 1]
path: conans/client/graph/graph_manager.py
text: @user1:
```python
conflicts = [it for it, nodes in provides.items() if len(nodes) > 1]
```