Repository: conan-io/conan

Issue:
Opt-in global full_version instead of semver for package_id
Consider adding an opt-in mechanism using the conan.conf to change the global behavior of the package_id method to default all packages (only default, not override if they have fine adjustments) so we can change the default `semver` to default `full_version`, that would be the default in Conan 2.0

Probably we should consider the default being configurable => `full_package_id`, `full_version`...


Discussion:
I'd also really like to see this on a per-package basis, where a package could declare how strictly it maintained binary compatibility and have that change its default representation in a consumer's info.requires["x"]. Obviously the consumer's package_id function could still change this, but in general "which versions are binary compatible" is something better known by the libfoo recipe than by the consumer.

Resolving patch:
diff --git a/conans/client/conf/__init__.py b/conans/client/conf/__init__.py
--- a/conans/client/conf/__init__.py
+++ b/conans/client/conf/__init__.py
@@ -351,6 +351,13 @@ def revisions_enabled(self):
         except ConanException:
             return False
 
+    @property
+    def default_package_id_mode(self):
+        try:
+            return self.get_item("general.default_package_id_mode")
+        except ConanException:
+            return "semver_direct_mode"
+
     @property
     def storage_path(self):
         # Try with CONAN_STORAGE_PATH
diff --git a/conans/client/graph/graph_binaries.py b/conans/client/graph/graph_binaries.py
--- a/conans/client/graph/graph_binaries.py
+++ b/conans/client/graph/graph_binaries.py
@@ -160,7 +160,7 @@ def _evaluate_node(self, node, build_mode, update, evaluated_nodes, remote_name)
         node.binary_remote = remote
 
     @staticmethod
-    def _compute_package_id(node):
+    def _compute_package_id(node, default_package_id_mode):
         conanfile = node.conanfile
         neighbors = node.neighbors()
         direct_reqs = []  # of PackageReference
@@ -184,7 +184,8 @@ def _compute_package_id(node):
         conanfile.info = ConanInfo.create(conanfile.settings.values,
                                           conanfile.options.values,
                                           direct_reqs,
-                                          indirect_reqs)
+                                          indirect_reqs,
+                                          default_package_id_mode=default_package_id_mode)
 
         # Once we are done, call package_id() to narrow and change possible values
         with conanfile_exception_formatter(str(conanfile), "package_id"):
@@ -203,9 +204,10 @@ def _handle_private(self, node):
                     self._handle_private(n)
 
     def evaluate_graph(self, deps_graph, build_mode, update, remote_name):
+        default_package_id_mode = self._cache.config.default_package_id_mode
         evaluated = deps_graph.evaluated
         for node in deps_graph.ordered_iterate():
-            self._compute_package_id(node)
+            self._compute_package_id(node, default_package_id_mode)
             if node.recipe in (RECIPE_CONSUMER, RECIPE_VIRTUAL):
                 continue
             self._evaluate_node(node, build_mode, update, evaluated, remote_name)
diff --git a/conans/model/info.py b/conans/model/info.py
--- a/conans/model/info.py
+++ b/conans/model/info.py
@@ -14,22 +14,28 @@
 
 
 class RequirementInfo(object):
-    def __init__(self, value_str, indirect=False):
-        """ parse the input into fields name, version...
-        """
-        pref = PackageReference.loads(value_str)
+    def __init__(self, pref, default_package_id_mode, indirect=False):
         self.package = pref
         self.full_name = pref.ref.name
         self.full_version = pref.ref.version
         self.full_user = pref.ref.user
         self.full_channel = pref.ref.channel
         self.full_package_id = pref.id
+        self._indirect = indirect
 
-        # sha values
-        if indirect:
-            self.unrelated_mode()
-        else:
-            self.semver()
+        try:
+            getattr(self, default_package_id_mode)()
+        except AttributeError:
+            raise ConanException("'%s' is not a known package_id_mode" % default_package_id_mode)
+
+    def copy(self):
+        # Useful for build_id()
+        result = RequirementInfo(self.package, "unrelated_mode")
+        for f in ("name", "version", "user", "channel", "package_id"):
+            setattr(result, f, getattr(self, f))
+            f = "full_%s" % f
+            setattr(result, f, getattr(self, f))
+        return result
 
     def dumps(self):
         if not self.name:
@@ -43,18 +49,25 @@ def dumps(self):
 
     @property
     def sha(self):
-        return "/".join([str(n) for n in [self.name, self.version, self.user, self.channel,
-                                          self.package_id]])
+        vals = [str(n) for n in (self.name, self.version, self.user, self.channel, self.package_id)]
+        # This is done later to NOT affect existing package-IDs (before revisions)
+        return "/".join(vals)
 
     def unrelated_mode(self):
         self.name = self.version = self.user = self.channel = self.package_id = None
 
+    def semver_direct_mode(self):
+        if self._indirect:
+            self.unrelated_mode()
+        else:
+            self.semver_mode()
+
     def semver_mode(self):
         self.name = self.full_name
         self.version = self.full_version.stable()
         self.user = self.channel = self.package_id = None
 
-    semver = semver_mode
+    semver = semver_mode  # Remove Conan 2.0
 
     def full_version_mode(self):
         self.name = self.full_name
@@ -97,12 +110,16 @@ def full_package_mode(self):
 
 
 class RequirementsInfo(object):
-    def __init__(self, requires):
+    def __init__(self, prefs, default_package_id_mode):
         # {PackageReference: RequirementInfo}
-        self._data = {r: RequirementInfo(str(r)) for r in requires}
+        self._data = {pref: RequirementInfo(pref, default_package_id_mode=default_package_id_mode)
+                      for pref in prefs}
 
     def copy(self):
-        return RequirementsInfo(self._data.keys())
+        # For build_id() implementation
+        result = RequirementsInfo([], None)
+        result._data = {pref: req_info.copy() for pref, req_info in self._data.items()}
+        return result
 
     def clear(self):
         self._data = {}
@@ -111,12 +128,13 @@ def remove(self, *args):
         for name in args:
             del self._data[self._get_key(name)]
 
-    def add(self, indirect_reqs):
+    def add(self, prefs_indirect, default_package_id_mode):
         """ necessary to propagate from upstream the real
         package requirements
         """
-        for r in indirect_reqs:
-            self._data[r] = RequirementInfo(str(r), indirect=True)
+        for r in prefs_indirect:
+            self._data[r] = RequirementInfo(r, indirect=True,
+                                            default_package_id_mode=default_package_id_mode)
 
     def refs(self):
         """ used for updating downstream requirements with this
@@ -146,7 +164,8 @@ def sha(self):
         # Remove requirements without a name, i.e. indirect transitive requirements
         data = {k: v for k, v in self._data.items() if v.name}
         for key in sorted(data):
-            result.append(data[key].sha)
+            s = data[key].sha
+            result.append(s)
         return sha1('\n'.join(result).encode())
 
     def dumps(self):
@@ -160,6 +179,10 @@ def dumps(self):
     def unrelated_mode(self):
         self.clear()
 
+    def semver_direct_mode(self):
+        for r in self._data.values():
+            r.semver_direct_mode()
+
     def semver_mode(self):
         for r in self._data.values():
             r.semver_mode()
@@ -218,17 +241,17 @@ def copy(self):
         return result
 
     @staticmethod
-    def create(settings, options, requires, indirect_requires):
+    def create(settings, options, prefs_direct, prefs_indirect, default_package_id_mode):
         result = ConanInfo()
         result.full_settings = settings
         result.settings = settings.copy()
         result.full_options = options
         result.options = options.copy()
         result.options.clear_indirect()
-        result.full_requires = _PackageReferenceList(requires)
-        result.requires = RequirementsInfo(requires)
-        result.requires.add(indirect_requires)
-        result.full_requires.extend(indirect_requires)
+        result.full_requires = _PackageReferenceList(prefs_direct)
+        result.requires = RequirementsInfo(prefs_direct, default_package_id_mode)
+        result.requires.add(prefs_indirect, default_package_id_mode)
+        result.full_requires.extend(prefs_indirect)
         result.recipe_hash = None
         result.env_values = EnvValues()
         result.vs_toolset_compatible()
@@ -239,6 +262,9 @@ def create(settings, options, requires, indirect_requires):
 
     @staticmethod
     def loads(text):
+        # This is used for search functionality, search prints info from this file
+        # Other use is from the BinariesAnalyzer, to get the recipe_hash and know
+        # if package is outdated
         parser = ConfigParser(text, ["settings", "full_settings", "options", "full_options",
                                      "requires", "full_requires", "scope", "recipe_hash", "env"],
                               raise_unexpected_field=False)
@@ -248,7 +274,8 @@ def loads(text):
         result.options = OptionsValues.loads(parser.options)
         result.full_options = OptionsValues.loads(parser.full_options)
         result.full_requires = _PackageReferenceList.loads(parser.full_requires)
-        result.requires = RequirementsInfo(result.full_requires)
+        # Requires after load are not used for any purpose, CAN'T be used, they are not correct
+        result.requires = RequirementsInfo(result.full_requires, "semver_direct_mode")
         result.recipe_hash = parser.recipe_hash or None
 
         # TODO: Missing handling paring of requires, but not necessary now
@@ -308,18 +335,15 @@ def package_id(self):
         """ The package_id of a conans is the sha1 of its specific requirements,
         options and settings
         """
-        package_id = getattr(self, "_package_id", None)
-        if package_id:
-            return package_id
-
         result = []
         result.append(self.settings.sha)
         # Only are valid requires for OPtions those Non-Dev who are still in requires
         self.options.filter_used(self.requires.pkg_names)
         result.append(self.options.sha)
-        result.append(self.requires.sha)
+        requires_sha = self.requires.sha
+        result.append(requires_sha)
+
         package_id = sha1('\n'.join(result).encode())
-        self._package_id = package_id
         return package_id
 
     def serialize_min(self):
@@ -335,7 +359,7 @@ def serialize_min(self):
     def header_only(self):
         self.settings.clear()
         self.options.clear()
-        self.requires.unrelated_mode()
+        self.requires.clear()
 
     def vs_toolset_compatible(self):
         """Default behaviour, same package for toolset v140 with compiler=Visual Studio 15 than


Test patch:
diff --git a/conans/test/functional/graph/graph_manager_base.py b/conans/test/functional/graph/graph_manager_base.py
new file mode 100644
--- /dev/null
+++ b/conans/test/functional/graph/graph_manager_base.py
@@ -0,0 +1,103 @@
+import os
+import unittest
+
+from mock import Mock
+
+from conans.client.cache.cache import ClientCache
+from conans.client.graph.graph_manager import GraphManager
+from conans.client.graph.proxy import ConanProxy
+from conans.client.graph.python_requires import ConanPythonRequire
+from conans.client.graph.range_resolver import RangeResolver
+from conans.client.installer import BinaryInstaller
+from conans.client.loader import ConanFileLoader
+from conans.client.recorder.action_recorder import ActionRecorder
+from conans.model.graph_info import GraphInfo
+from conans.model.manifest import FileTreeManifest
+from conans.model.options import OptionsValues
+from conans.model.profile import Profile
+from conans.model.ref import ConanFileReference
+from conans.test.unittests.model.transitive_reqs_test import MockSearchRemote
+from conans.test.utils.conanfile import TestConanFile
+from conans.test.utils.test_files import temp_folder
+from conans.test.utils.tools import TestBufferConanOutput
+from conans.util.files import save
+
+
+class GraphManagerTest(unittest.TestCase):
+
+    def setUp(self):
+        self.output = TestBufferConanOutput()
+        cache_folder = temp_folder()
+        cache = ClientCache(cache_folder, os.path.join(cache_folder, ".conan"), self.output)
+        self.cache = cache
+        self.remote_search = MockSearchRemote()
+        remote_manager = None
+        self.resolver = RangeResolver(cache, self.remote_search)
+        proxy = ConanProxy(cache, self.output, remote_manager)
+        self.loader = ConanFileLoader(None, self.output, ConanPythonRequire(None, None))
+        self.manager = GraphManager(self.output, cache, remote_manager, self.loader, proxy,
+                                    self.resolver)
+        hook_manager = Mock()
+        recorder = Mock()
+        self.binary_installer = BinaryInstaller(cache, self.output, remote_manager, recorder,
+                                                hook_manager)
+
+    def _cache_recipe(self, reference, test_conanfile, revision=None):
+        if isinstance(test_conanfile, TestConanFile):
+            test_conanfile.info = True
+        ref = ConanFileReference.loads(reference)
+        save(self.cache.conanfile(ref), str(test_conanfile))
+        with self.cache.package_layout(ref).update_metadata() as metadata:
+            metadata.recipe.revision = revision or "123"
+        manifest = FileTreeManifest.create(self.cache.export(ref))
+        manifest.save(self.cache.export(ref))
+
+    def build_graph(self, content, profile_build_requires=None, ref=None):
+        path = temp_folder()
+        path = os.path.join(path, "conanfile.py")
+        save(path, str(content))
+        self.loader.cached_conanfiles = {}
+
+        profile = Profile()
+        if profile_build_requires:
+            profile.build_requires = profile_build_requires
+        profile.process_settings(self.cache)
+        update = check_updates = False
+        recorder = ActionRecorder()
+        remote_name = None
+        build_mode = []
+        ref = ref or ConanFileReference(None, None, None, None, validate=False)
+        options = OptionsValues()
+        graph_info = GraphInfo(profile, options, root_ref=ref)
+        deps_graph, _ = self.manager.load_graph(path, None, graph_info,
+                                                build_mode, check_updates, update,
+                                                remote_name, recorder)
+        self.binary_installer.install(deps_graph, False, graph_info)
+        return deps_graph
+
+    def _check_node(self, node, ref, deps, build_deps, dependents, closure, public_deps):
+        conanfile = node.conanfile
+        ref = ConanFileReference.loads(str(ref))
+        self.assertEqual(node.ref.full_repr(), ref.full_repr())
+        self.assertEqual(conanfile.name, ref.name)
+        self.assertEqual(len(node.dependencies), len(deps) + len(build_deps))
+        self.assertEqual(len(node.dependants), len(dependents))
+
+        public_deps = {n.name: n for n in public_deps}
+        self.assertEqual(node.public_deps, public_deps)
+
+        # The recipe requires is resolved to the reference WITH revision!
+        self.assertEqual(len(deps), len(conanfile.requires))
+        for dep in deps:
+            self.assertEqual(conanfile.requires[dep.name].ref,
+                             dep.ref)
+
+        self.assertEqual(closure, node.public_closure)
+        libs = []
+        envs = []
+        for n in closure:
+            libs.append("mylib%s%slib" % (n.ref.name, n.ref.version))
+            envs.append("myenv%s%senv" % (n.ref.name, n.ref.version))
+        self.assertEqual(conanfile.deps_cpp_info.libs, libs)
+        env = {"MYENV": envs} if envs else {}
+        self.assertEqual(conanfile.deps_env_info.vars, env)
diff --git a/conans/test/functional/graph/package_id_modes_test.py b/conans/test/functional/graph/package_id_modes_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/functional/graph/package_id_modes_test.py
@@ -0,0 +1,35 @@
+from conans.test.functional.graph.graph_manager_base import GraphManagerTest
+from conans.test.utils.conanfile import TestConanFile
+
+
+class PackageIDGraphTests(GraphManagerTest):
+
+    def test_recipe_modes(self):
+        configs = []
+        mode = "semver_mode"
+        configs.append((mode, "liba/1.1.1@user/testing", "8ecbf93ba63522ffb32573610c80ab4dcb399b52"))
+        configs.append((mode, "liba/1.1.2@user/testing", "8ecbf93ba63522ffb32573610c80ab4dcb399b52"))
+        configs.append((mode, "liba/1.2.1@other/stable", "8ecbf93ba63522ffb32573610c80ab4dcb399b52"))
+        mode = "patch_mode"
+        configs.append((mode, "liba/1.1.1@user/testing", "bd664570d5174c601d5d417bc19257c4dba48f2e"))
+        configs.append((mode, "liba/1.1.2@user/testing", "fb1f766173191d44b67156c6b9ac667422e99286"))
+        configs.append((mode, "liba/1.1.1@other/stable", "bd664570d5174c601d5d417bc19257c4dba48f2e"))
+        mode = "full_recipe_mode"
+        configs.append((mode, "liba/1.1.1@user/testing", "9cbe703e1dee73a2a6807f71d8551c5f1e1b08fd"))
+        configs.append((mode, "liba/1.1.2@user/testing", "42a9ff9024adabbd54849331cf01be7d95139948"))
+        configs.append((mode, "liba/1.1.1@user/stable", "b41d6c026473cffed4abded4b0eaa453497be1d2"))
+
+        for package_id_mode, ref, package_id in configs:
+            self.cache.config.set_item("general.default_package_id_mode", package_id_mode)
+            libb_ref = "libb/0.1@user/testing"
+            self._cache_recipe(ref, TestConanFile("liba", "0.1.1"))
+            self._cache_recipe(libb_ref, TestConanFile("libb", "0.1", requires=[ref]))
+            deps_graph = self.build_graph(TestConanFile("app", "0.1", requires=[libb_ref]))
+
+            self.assertEqual(3, len(deps_graph.nodes))
+            app = deps_graph.root
+            libb = app.dependencies[0].dst
+            liba = libb.dependencies[0].dst
+
+            self.assertEqual(liba.package_id, "5ab84d6acfe1f23c4fae0ab88f26e3a396351ac9")
+            self.assertEqual(libb.package_id, package_id)
diff --git a/conans/test/integration/build_id_test.py b/conans/test/integration/build_id_test.py
--- a/conans/test/integration/build_id_test.py
+++ b/conans/test/integration/build_id_test.py
@@ -9,22 +9,18 @@
 
 conanfile = """from conans import ConanFile
 from conans.util.files import save
-
 class MyTest(ConanFile):
     name = "Pkg"
     version = "0.1"
     settings = "os", "build_type"
     build_policy = "missing"
-
     def build_id(self):
         if self.settings.os == "Windows":
             self.info_build.settings.build_type = "Any"
-
     def build(self):
         self.output.info("Building my code!")
         save("debug/file1.txt", "Debug file1")
         save("release/file1.txt", "Release file1")
-
     def package(self):
         self.output.info("Packaging %s!" % self.settings.build_type)
         if self.settings.build_type == "Debug":
@@ -40,18 +36,14 @@ def package(self):
 """
 
 consumer_py = """from conans import ConanFile
-
-
 class MyTest(ConanFile):
     name = "MyTest"
     version = "0.1"
     settings = "os", "build_type"
     requires = "Pkg/0.1@user/channel"
-
     def build_id(self):
         self.info_build.settings.build_type = "Any"
         self.info_build.requires.clear()
-
     def imports(self):
         self.copy("*")
 """
@@ -120,6 +112,7 @@ def basic_test(self, python_consumer):
             client.save({"conanfile.py": consumer_py}, clean_first=True)
         else:
             client.save({"conanfile.txt": consumer}, clean_first=True)
+        # Windows Debug
         client.run('install . -s os=Windows -s build_type=Debug')
         self.assertIn("Building package from source as defined by build_policy='missing'",
                       client.user_io.out)
@@ -127,12 +120,14 @@ def basic_test(self, python_consumer):
         self.assertIn("Packaging Debug!", client.user_io.out)
         content = load(os.path.join(client.current_folder, "file1.txt"))
         self.assertEqual("Debug file1", content)
+        # Package Windows Release, it will reuse the previous build
         client.run('install . -s os=Windows -s build_type=Release')
         self.assertNotIn("Building my code!", client.user_io.out)
         self.assertIn("Packaging Release!", client.user_io.out)
         content = load(os.path.join(client.current_folder, "file1.txt"))
         self.assertEqual("Release file1", content)
-        # Now Linux
+
+        # Now Linux Debug
         client.run('install . -s os=Linux -s build_type=Debug')
         self.assertIn("Building package from source as defined by build_policy='missing'",
                       client.user_io.out)
@@ -140,6 +135,7 @@ def basic_test(self, python_consumer):
         self.assertIn("Packaging Debug!", client.user_io.out)
         content = load(os.path.join(client.current_folder, "file1.txt"))
         self.assertEqual("Debug file1", content)
+        # Linux Release must build again, as it is not affected by build_id()
         client.run('install . -s os=Linux -s build_type=Release')
         self.assertIn("Building my code!", client.user_io.out)
         self.assertIn("Packaging Release!", client.user_io.out)
@@ -149,11 +145,13 @@ def basic_test(self, python_consumer):
 
         # Check that repackaging works, not necessary to re-build
         client.run("remove Pkg/0.1@user/channel -p -f")
+        # Windows Debug
         client.run('install . -s os=Windows -s build_type=Debug')
         self.assertNotIn("Building my code!", client.user_io.out)
         self.assertIn("Packaging Debug!", client.user_io.out)
         content = load(os.path.join(client.current_folder, "file1.txt"))
         self.assertEqual("Debug file1", content)
+        # Windows Release
         client.run('install . -s os=Windows -s build_type=Release')
         self.assertNotIn("Building my code!", client.user_io.out)
         self.assertIn("Packaging Release!", client.user_io.out)
diff --git a/conans/test/unittests/model/transitive_reqs_test.py b/conans/test/unittests/model/transitive_reqs_test.py
--- a/conans/test/unittests/model/transitive_reqs_test.py
+++ b/conans/test/unittests/model/transitive_reqs_test.py
@@ -1,10 +1,16 @@
 import unittest
 from collections import namedtuple, Counter
 
+from mock import Mock
+
 from conans import DEFAULT_REVISION_V1
+from conans.client.cache.cache import ClientCache
 from conans.client.conf import default_settings_yml
+from conans.client.graph.build_mode import BuildMode
+from conans.client.graph.graph_binaries import GraphBinariesAnalyzer
 from conans.client.graph.graph_builder import DepsGraphBuilder
 from conans.client.graph.python_requires import ConanPythonRequire
+from conans.client.graph.range_resolver import RangeResolver
 from conans.client.loader import ConanFileLoader
 from conans.errors import ConanException
 from conans.model.options import OptionsValues, option_not_exist_msg, option_wrong_value_msg
@@ -14,17 +20,9 @@
 from conans.model.settings import Settings, bad_value_msg
 from conans.model.values import Values
 from conans.test.unittests.model.fake_retriever import Retriever
-from conans.test.utils.tools import NO_SETTINGS_PACKAGE_ID, TestBufferConanOutput,\
-    test_processed_profile
-import os
-
-from mock import Mock
-
-from conans.client.cache.cache import ClientCache
-from conans.client.graph.build_mode import BuildMode
-from conans.client.graph.graph_binaries import GraphBinariesAnalyzer
-from conans.client.graph.range_resolver import RangeResolver
 from conans.test.utils.conanfile import TestConanFile
+from conans.test.utils.tools import (NO_SETTINGS_PACKAGE_ID, TestBufferConanOutput,
+                                     test_processed_profile)
 
 
 say_content = TestConanFile("Say", "0.1")
@@ -88,6 +86,7 @@ def setUp(self):
         self.builder = DepsGraphBuilder(self.retriever, self.output, self.loader,
                                         self.resolver, None)
         cache = Mock()
+        cache.config.default_package_id_mode = "semver_direct_mode"
         remote_manager = None
         self.binaries_analyzer = GraphBinariesAnalyzer(cache, self.output, remote_manager)
 
@@ -1100,8 +1099,8 @@ class HelloConan(ConanFile):
     Previous requirements: [Base/0.1@user/testing, png/0.1@user/testing]
     New requirements: [Base/0.1@user/testing, Zlib/0.1@user/testing]"""
         try:
-            _ = self.build_graph(TestConanFile("Chat", "2.3", requires=["Say/0.1@user/testing",
-                                                                        "Hello/1.2@user/testing"]))
+            self.build_graph(TestConanFile("Chat", "2.3", requires=["Say/0.1@user/testing",
+                                                                    "Hello/1.2@user/testing"]))
             self.assert_(False, "Exception not thrown")
         except ConanException as e:
             self.assertEqual(str(e), expected)