Repository: conan-io/conan
Pull request #17647 (2025-01-27 17:57:19)

Fix cppstd_compat migration for 2.12
Changelog: Bugfix: Avoid ``compatibility.py`` migration if any of the files are modified by users.
Docs: Omit




Patch:
diff --git a/conan/api/subapi/config.py b/conan/api/subapi/config.py
index 0267766a1..5f4abcb65 100644
--- a/conan/api/subapi/config.py
+++ b/conan/api/subapi/config.py
@@ -208,7 +208,7 @@ class ConfigAPI:
         packages_folder = self.global_conf.get("core.cache:storage_path") or os.path.join(self.home(), "p")
         for content in contents:
             content_path = os.path.join(self.home(), content)
-            if content_path == packages_folder or content == "version.txt":
+            if content_path == packages_folder:
                 continue
             ConanOutput().debug(f"Removing {content_path}")
             if os.path.isdir(content_path):
diff --git a/conan/cli/commands/config.py b/conan/cli/commands/config.py
index 8454d1401..1f78df37a 100644
--- a/conan/cli/commands/config.py
+++ b/conan/cli/commands/config.py
@@ -138,7 +138,7 @@ def config_show(conan_api, parser, subparser, *args):
 @conan_subcommand()
 def config_clean(conan_api, parser, subparser, *args):
     """
-    Clean the configuration files in the Conan home folder. (Keeping installed packages)
+    (Experimental) Clean the configuration files in the Conan home folder, while keeping installed packages
     """
     parser.parse_args(*args)
     conan_api.config.clean()
diff --git a/conans/client/graph/compatibility.py b/conans/client/graph/compatibility.py
index d69e64eff..ddfe1e501 100644
--- a/conans/client/graph/compatibility.py
+++ b/conans/client/graph/compatibility.py
@@ -1,17 +1,49 @@
 import os
 from collections import OrderedDict
 
+from conan.api.output import ConanOutput
 from conan.internal.cache.home_paths import HomePaths
 from conans.client.graph.compute_pid import run_validate_package_id
 from conans.client.loader import load_python_file
 from conan.internal.errors import conanfile_exception_formatter, scoped_traceback
 from conan.errors import ConanException
+from conans.client.migrations import CONAN_GENERATED_COMMENT
 
 # TODO: Define other compatibility besides applications
+from conans.util.files import load, save
+
 _default_compat = """\
 # This file was generated by Conan. Remove this comment if you edit this file or Conan
 # will destroy your changes.
-from cppstd_compat import cppstd_compat
+
+from conan.tools.build import supported_cppstd, supported_cstd
+from conan.errors import ConanException
+
+
+def cppstd_compat(conanfile):
+    # It will try to find packages with all the cppstd versions
+    extension_properties = getattr(conanfile, "extension_properties", {})
+    compiler = conanfile.settings.get_safe("compiler")
+    compiler_version = conanfile.settings.get_safe("compiler.version")
+    cppstd = conanfile.settings.get_safe("compiler.cppstd")
+    if not compiler or not compiler_version:
+        return []
+    factors = []  # List of list, each sublist is a potential combination
+    if cppstd is not None and extension_properties.get("compatibility_cppstd") is not False:
+        cppstd_possible_values = supported_cppstd(conanfile)
+        if cppstd_possible_values is None:
+            conanfile.output.warning(f'No cppstd compatibility defined for compiler "{compiler}"')
+        else: # The current cppst must be included in case there is other factor
+            factors.append([{"compiler.cppstd": v} for v in cppstd_possible_values])
+
+    cstd = conanfile.settings.get_safe("compiler.cstd")
+    if cstd is not None and extension_properties.get("compatibility_cstd") is not False:
+        cstd_possible_values = supported_cstd(conanfile)
+        if cstd_possible_values is None:
+            conanfile.output.warning(f'No cstd compatibility defined for compiler "{compiler}"')
+        else:
+            factors.append([{"compiler.cstd": v} for v in cstd_possible_values if v != cstd])
+    return factors
 
 
 def compatibility(conanfile):
@@ -52,47 +84,24 @@ def _factors_combinations(factors):
 """
 
 
-_default_cppstd_compat = """\
-# This file was generated by Conan. Remove this comment if you edit this file or Conan
-# will destroy your changes.
-from conan.tools.build import supported_cppstd, supported_cstd
-from conan.errors import ConanException
-
-
-def cppstd_compat(conanfile):
-    # It will try to find packages with all the cppstd versions
-    extension_properties = getattr(conanfile, "extension_properties", {})
-    compiler = conanfile.settings.get_safe("compiler")
-    compiler_version = conanfile.settings.get_safe("compiler.version")
-    cppstd = conanfile.settings.get_safe("compiler.cppstd")
-    if not compiler or not compiler_version:
-        return []
-    factors = []  # List of list, each sublist is a potential combination
-    if cppstd is not None and extension_properties.get("compatibility_cppstd") is not False:
-        cppstd_possible_values = supported_cppstd(conanfile)
-        if cppstd_possible_values is None:
-            conanfile.output.warning(f'No cppstd compatibility defined for compiler "{compiler}"')
-        else: # The current cppst must be included in case there is other factor
-            factors.append([{"compiler.cppstd": v} for v in cppstd_possible_values])
-
-    cstd = conanfile.settings.get_safe("compiler.cstd")
-    if cstd is not None and extension_properties.get("compatibility_cstd") is not False:
-        cstd_possible_values = supported_cstd(conanfile)
-        if cstd_possible_values is None:
-            conanfile.output.warning(f'No cstd compatibility defined for compiler "{compiler}"')
-        else:
-            factors.append([{"compiler.cstd": v} for v in cstd_possible_values if v != cstd])
-    return factors
-"""
-
-
 def migrate_compatibility_files(cache_folder):
-    from conans.client.migrations import update_file
     compatible_folder = HomePaths(cache_folder).compatibility_plugin_path
     compatibility_file = os.path.join(compatible_folder, "compatibility.py")
     cppstd_compat_file = os.path.join(compatible_folder, "cppstd_compat.py")
-    update_file(compatibility_file, _default_compat)
-    update_file(cppstd_compat_file, _default_cppstd_compat)
+
+    def _should_migrate_file(file_path):
+        if not os.path.exists(file_path):
+            return True
+        content = load(file_path)
+        first_line = content.lstrip().split("\n", 1)[0]
+        return CONAN_GENERATED_COMMENT in first_line
+
+    if _should_migrate_file(compatibility_file) and _should_migrate_file(cppstd_compat_file):
+        if os.path.exists(compatibility_file) and load(compatibility_file) != _default_compat:
+            ConanOutput().success("Migration: Successfully updated compatibility.py}")
+        save(compatibility_file, _default_compat)
+        if os.path.exists(cppstd_compat_file):
+            os.remove(cppstd_compat_file)
 
 
 class BinaryCompatibility:


Test patch:
diff --git a/test/integration/command/config_test.py b/test/integration/command/config_test.py
index c84ea3e36..55a34909b 100644
--- a/test/integration/command/config_test.py
+++ b/test/integration/command/config_test.py
@@ -232,8 +232,14 @@ def test_config_clean(storage_path):
     assert "bar" not in tc.out
     tc.run("config show core.upload:retry")
     assert "7" not in tc.out
-    assert not os.path.exists(os.path.join(tc.cache_folder, "extensions"))
+    assert os.path.exists(os.path.join(tc.cache_folder, "extensions"))
+    assert not os.path.exists(os.path.join(tc.cache_folder, "extensions", "compatibility", "mycomp.py"))
     assert os.path.exists(absolut_storage_path)
+    # This will error because the call to clean will remove the profiles
+    tc.run("create .", assert_error=True)
+    # Works after regenerating them!
+    tc.run("profile detect")
+    tc.run("create .")
 
 
 def test_config_reinit():
diff --git a/test/integration/test_migrations.py b/test/integration/test_migrations.py
index 233b09729..5392ab1b9 100644
--- a/test/integration/test_migrations.py
+++ b/test/integration/test_migrations.py
@@ -31,8 +31,7 @@ def _drop_lru_column(db_folder):
 
 @pytest.mark.parametrize(["plugin_path", "string_replace", "new_string"],
                          [("profile.py", "msvc", "EME_ESE_VC"),
-                          ("compatibility/compatibility.py", "conanfile", "conian_file"),
-                          ("compatibility/cppstd_compat.py", "conanfile", "conian_file")])
+                          ("compatibility/compatibility.py", "conanfile", "conian_file")])
 def test_migration_profile_checker_plugin(plugin_path, string_replace, new_string):
     t = TestClient()
     # Any command that checks the package cache generates the DB
@@ -151,3 +150,44 @@ def test_back_default_compatibility_migration():
     with patch('conan.api.conan_api.ClientMigrator', new=lambda *args, **kwargs: migrator):
         t.run("-v")  # Fire the backward migration
         assert f"WARN: Downgrading cache from Conan {conan_version} to 2.3.2" in t.out
+
+
+class TestMigrationCppstdCompat:
+    def test_migration(self):
+        t = TestClient()
+        t.run("-v")
+        cppstd_compat_path = "extensions/plugins/compatibility/cppstd_compat.py"
+        compatibility_path = "extensions/plugins/compatibility/compatibility.py"
+
+        # both files exist and not modified
+        t.save_home({"version.txt": "2.11"})
+        t.run("-v")
+        assert "def cppstd_compat(conanfile)" in t.load_home(compatibility_path)
+        assert not os.path.exists(os.path.join(t.cache_folder, cppstd_compat_path))
+
+    def test_cppstd_modified(self):
+        t = TestClient()
+        t.run("-v")
+        cppstd_compat_path = "extensions/plugins/compatibility/cppstd_compat.py"
+        compatibility_path = "extensions/plugins/compatibility/compatibility.py"
+        # cppstd_compat modified
+        t.save_home({"version.txt": "2.11",
+                     compatibility_path: "# This file was generated by Conan",
+                     cppstd_compat_path: "custom file content"})
+        t.run("-v")
+        assert t.load_home(cppstd_compat_path) == "custom file content"
+        # compatibility not migrated, keeps the old content
+        assert "def cppstd_compat(conanfile)" not in t.load_home(compatibility_path)
+
+    def test_compatibility_modified(self):
+        t = TestClient()
+        t.run("-v")
+        cppstd_compat_path = "extensions/plugins/compatibility/cppstd_compat.py"
+        compatibility_path = "extensions/plugins/compatibility/compatibility.py"
+        t.save_home({"version.txt": "2.11",
+                     cppstd_compat_path: "# This file was generated by Conan POTATO",
+                     compatibility_path: "Modified file"})
+        t.run("-v")
+        assert t.load_home(compatibility_path) == "Modified file"
+        # not Removed because compatibility was modified
+        assert "POTATO" in t.load_home(cppstd_compat_path)