Repository: conan-io/conan

Issue:
Compare compiler.cppstd version (#4943)
Extends `conans.tools.Version` to support `settings.compiler.cppstd`:

```
def configure(self):
    if Version(self.settings.compiler.cppstd) < "gnu17":
        ...
```

`Version` from `model.version` was not affected.

Changelog: Feature: Compare compiler.cppstd version (#4943)
Docs: https://github.com/conan-io/docs/pull/1440
closes #4943

- [x] Refer to the issue that supports this Pull Request.
- [x] If the issue has missing info, explain the purpose/use case/pain/need that covers this Pull Request.
- [x] I've read the [Contributing guide](https://github.com/conan-io/conan/blob/develop/.github/CONTRIBUTING.md).
- [x] I've followed the PEP8 style guides for Python code.
- [x] I've opened another PR in the Conan docs repo to the ``develop`` branch, documenting this one. 

<sup>**Note:** By default this PR will skip the slower tests and will use a limited set of python versions. Check [here](https://github.com/conan-io/conan/blob/develop/.github/PR_INCREASE_TESTING.md) how to increase the testing level by writing some tags in the current PR body text.</sup>



Discussion:
Ops! Jenkins is not okay.

> Cause: jenkins.branch.BranchIndexingCause


Uh, I don't like to mix the Version class for that.
We should have agreed first on how it should look. Please write some suggestions.
@lasote What about creating a new class, specific for cppstd and using SettingsItem as base? We can override some operators:

```python
class SettingsCppSTD(SettingsItem):

    def __eq__(self, other):
        other = ''.join(filter(str.isdigit, str(other)))
        current = ''.join(filter(str.isdigit, self.__str__()))
        return other == current
```
No, it introduces the need for conan to manage a different type for that setting, just for comparison is not worth it.
Another helper function to used from tools?

```python
def cppstd_compare(cppstd_a, cppstd_b):
    a = ''.join(filter(str.isdigit, str(cppstd_a)))
    b = ''.join(filter(str.isdigit, str(cppstd_b)))
    if a > b:
        return 1
    elif b > a:
        return -1
    else:
        return 0
```
		
I think that is the less invasive and easier approach, yes.
@uilianries Please, convert to an isolated tool,  we will think about releasing this for 1.20.
> @uilianries Please, convert to an isolated tool, we will think about releasing this for 1.20.

@lasote Done, please review again.
@uilianries introduce the modification at `SettingsItem` as I suggested. I think it will be better that way
Nice, I'll check later. I thought this PR was already merged.
@lasote Done!

Also, I've updated equals operator to validate '11' == 'gnu11' as true.
So we don't have an agreement on how should it look. Unless we have a clear and closed proposal soon this will be postponed.

Resolving patch:
diff --git a/conans/client/tools/__init__.py b/conans/client/tools/__init__.py
--- a/conans/client/tools/__init__.py
+++ b/conans/client/tools/__init__.py
@@ -15,6 +15,8 @@
 # noinspection PyUnresolvedReferences
 from .scm import *
 # noinspection PyUnresolvedReferences
+from .settings import *
+# noinspection PyUnresolvedReferences
 from .system_pm import *
 # noinspection PyUnresolvedReferences
 from .win import *
diff --git a/conans/client/tools/settings.py b/conans/client/tools/settings.py
new file mode 100644
--- /dev/null
+++ b/conans/client/tools/settings.py
@@ -0,0 +1,71 @@
+from conans.client.build.cppstd_flags import cppstd_default
+from conans.errors import ConanInvalidConfiguration, ConanException
+
+
+def check_min_cppstd(conanfile, cppstd, gnu_extensions=False):
+    """ Check if current cppstd fits the minimal version required.
+
+        In case the current cppstd doesn't fit the minimal version required
+        by cppstd, a ConanInvalidConfiguration exception will be raised.
+
+        1. If settings.compiler.cppstd, the tool will use settings.compiler.cppstd to compare
+        2. It not settings.compiler.cppstd, the tool will use compiler to compare (reading the
+           default from cppstd_default)
+        3. If not settings.compiler is present (not declared in settings) will raise because it
+           cannot compare.
+
+    :param conanfile: ConanFile instance with cppstd to be compared
+    :param cppstd: Minimal cppstd version required
+    :param gnu_extensions: GNU extension is required (e.g gnu17)
+    """
+    if not str(cppstd).isdigit():
+        raise ConanException("cppstd parameter must be a number")
+
+    def less_than(lhs, rhs):
+        def extract_cpp_version(_cppstd):
+            return str(_cppstd).replace("gnu", "")
+
+        def add_millennium(_cppstd):
+            return "19%s" % _cppstd if _cppstd == "98" else "20%s" % _cppstd
+
+        lhs = add_millennium(extract_cpp_version(lhs))
+        rhs = add_millennium(extract_cpp_version(rhs))
+        return lhs < rhs
+
+    def check_required_gnu_extension(_cppstd):
+        if gnu_extensions and "gnu" not in _cppstd:
+            raise ConanInvalidConfiguration("The cppstd GNU extension is required")
+
+    def deduced_cppstd():
+        cppstd = conanfile.settings.get_safe("compiler.cppstd")
+        if cppstd:
+            return cppstd
+
+        compiler = conanfile.settings.get_safe("compiler")
+        compiler_version = conanfile.settings.get_safe("compiler.version")
+        if not compiler or not compiler_version:
+            raise ConanException("Could not obtain cppstd because there is no declared "
+                                 "compiler in the 'settings' field of the recipe.")
+        return cppstd_default(compiler, compiler_version)
+
+    current_cppstd = deduced_cppstd()
+    check_required_gnu_extension(current_cppstd)
+
+    if less_than(current_cppstd, cppstd):
+        raise ConanInvalidConfiguration("Current cppstd ({}) is lower than the required C++ "
+                                        "standard ({}).".format(current_cppstd, cppstd))
+
+
+def valid_min_cppstd(conanfile, cppstd, gnu_extensions=False):
+    """ Validate if current cppstd fits the minimal version required.
+
+    :param conanfile: ConanFile instance with cppstd to be compared
+    :param cppstd: Minimal cppstd version required
+    :param gnu_extensions: GNU extension is required (e.g gnu17). This option ONLY works on Linux.
+    :return: True, if current cppstd matches the required cppstd version. Otherwise, False.
+    """
+    try:
+        check_min_cppstd(conanfile, cppstd, gnu_extensions)
+    except ConanInvalidConfiguration:
+        return False
+    return True
diff --git a/conans/tools.py b/conans/tools.py
--- a/conans/tools.py
+++ b/conans/tools.py
@@ -19,6 +19,7 @@
 from conans.client.tools.env import *  # pylint: disable=unused-import
 from conans.client.tools.pkg_config import *  # pylint: disable=unused-import
 from conans.client.tools.scm import *  # pylint: disable=unused-import
+from conans.client.tools.settings import *  # pylint: disable=unused-import
 from conans.client.tools.apple import *
 from conans.client.tools.android import *
 # Tools form conans.util


Test patch:
diff --git a/conans/test/functional/tools/cppstd_minimum_version_test.py b/conans/test/functional/tools/cppstd_minimum_version_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/functional/tools/cppstd_minimum_version_test.py
@@ -0,0 +1,51 @@
+import unittest
+from parameterized import parameterized
+from textwrap import dedent
+
+from conans.test.utils.tools import TestClient
+
+
+class CppStdMinimumVersionTests(unittest.TestCase):
+
+    CONANFILE = dedent("""
+        import os
+        from conans import ConanFile
+        from conans.tools import check_min_cppstd, valid_min_cppstd
+
+        class Fake(ConanFile):
+            name = "fake"
+            version = "0.1"
+            settings = "compiler"
+
+            def configure(self):
+                check_min_cppstd(self, "17", False)
+                self.output.info("valid standard")
+                assert valid_min_cppstd(self, "17", False)
+        """)
+
+    PROFILE = dedent("""
+        [settings]
+        compiler=gcc
+        compiler.version=9
+        compiler.libcxx=libstdc++
+        {}
+        """)
+
+    def setUp(self):
+        self.client = TestClient()
+        self.client.save({"conanfile.py": CppStdMinimumVersionTests.CONANFILE})
+
+    @parameterized.expand(["17", "gnu17"])
+    def test_cppstd_from_settings(self, cppstd):
+        profile = CppStdMinimumVersionTests.PROFILE.replace("{}", "compiler.cppstd=%s" % cppstd)
+        self.client.save({"myprofile":  profile})
+        self.client.run("create . user/channel -pr myprofile")
+        self.assertIn("valid standard", self.client.out)
+
+    @parameterized.expand(["11", "gnu11"])
+    def test_invalid_cppstd_from_settings(self, cppstd):
+        profile = CppStdMinimumVersionTests.PROFILE.replace("{}", "compiler.cppstd=%s" % cppstd)
+        self.client.save({"myprofile": profile})
+        self.client.run("create . user/channel -pr myprofile", assert_error=True)
+        self.assertIn("Invalid configuration: Current cppstd (%s) is lower than the required C++ "
+                      "standard (17)." % cppstd, self.client.out)
diff --git a/conans/test/unittests/client/tools/cppstd_required_test.py b/conans/test/unittests/client/tools/cppstd_required_test.py
new file mode 100644
--- /dev/null
+++ b/conans/test/unittests/client/tools/cppstd_required_test.py
@@ -0,0 +1,158 @@
+import unittest
+from mock import mock
+from parameterized import parameterized
+
+from conans.test.utils.conanfile import MockConanfile, MockSettings
+from conans.client.tools import OSInfo
+from conans.errors import ConanInvalidConfiguration, ConanException
+
+from conans.tools import check_min_cppstd, valid_min_cppstd
+
+
+class UserInputTests(unittest.TestCase):
+
+    def test_check_cppstd_type(self):
+        """ cppstd must be a number
+        """
+        conanfile = MockConanfile(MockSettings({}))
+        with self.assertRaises(ConanException) as raises:
+            check_min_cppstd(conanfile, "gnu17", False)
+        self.assertEqual("cppstd parameter must be a number", str(raises.exception))
+
+
+class CheckMinCppStdTests(unittest.TestCase):
+
+    def _create_conanfile(self, compiler, version, os, cppstd, libcxx=None):
+        settings = MockSettings({"arch": "x86_64",
+                                 "build_type": "Debug",
+                                 "os": os,
+                                 "compiler": compiler,
+                                 "compiler.version": version,
+                                 "compiler.cppstd": cppstd})
+        if libcxx:
+            settings.values["compiler.libcxx"] = libcxx
+        conanfile = MockConanfile(settings)
+        return conanfile
+
+    @parameterized.expand(["98", "11", "14", "17"])
+    def test_check_min_cppstd_from_settings(self, cppstd):
+        """ check_min_cppstd must accept cppstd less/equal than cppstd in settings
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", "17", "libstdc++")
+        check_min_cppstd(conanfile, cppstd, False)
+
+    @parameterized.expand(["98", "11", "14"])
+    def test_check_min_cppstd_from_outdated_settings(self, cppstd):
+        """ check_min_cppstd must raise when cppstd is greater when supported on settings
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", cppstd, "libstdc++")
+        with self.assertRaises(ConanInvalidConfiguration) as raises:
+            check_min_cppstd(conanfile, "17", False)
+        self.assertEqual("Current cppstd ({}) is lower than the required C++ standard "
+                         "(17).".format(cppstd), str(raises.exception))
+
+    @parameterized.expand(["98", "11", "14", "17"])
+    def test_check_min_cppstd_from_settings_with_extension(self, cppstd):
+        """ current cppstd in settings must has GNU extension when extensions is enabled
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", "gnu17", "libstdc++")
+        check_min_cppstd(conanfile, cppstd, True)
+
+        conanfile.settings.values["compiler.cppstd"] = "17"
+        with self.assertRaises(ConanException) as raises:
+            check_min_cppstd(conanfile, cppstd, True)
+        self.assertEqual("The cppstd GNU extension is required", str(raises.exception))
+
+    def test_check_min_cppstd_unsupported_standard(self):
+        """ check_min_cppstd must raise when the compiler does not support a standard
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", None, "libstdc++")
+        with self.assertRaises(ConanInvalidConfiguration) as raises:
+            check_min_cppstd(conanfile, "42", False)
+        self.assertEqual("Current cppstd (gnu14) is lower than the required C++ standard (42).",
+                         str(raises.exception))
+
+    def test_check_min_cppstd_gnu_compiler_extension(self):
+        """ Current compiler must support GNU extension on Linux when extensions is required
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", None, "libstdc++")
+        with mock.patch("platform.system", mock.MagicMock(return_value="Linux")):
+            with mock.patch.object(OSInfo, '_get_linux_distro_info'):
+                with mock.patch("conans.client.tools.settings.cppstd_default", return_value="17"):
+                    with self.assertRaises(ConanException) as raises:
+                        check_min_cppstd(conanfile, "17", True)
+                    self.assertEqual("The cppstd GNU extension is required", str(raises.exception))
+
+    def test_no_compiler_declared(self):
+        conanfile = self._create_conanfile(None, None, "Linux", None, "libstdc++")
+        with self.assertRaises(ConanException) as raises:
+            check_min_cppstd(conanfile, "14", False)
+        self.assertEqual("Could not obtain cppstd because there is no declared compiler in the "
+                         "'settings' field of the recipe.", str(raises.exception))
+
+
+class ValidMinCppstdTests(unittest.TestCase):
+
+    def _create_conanfile(self, compiler, version, os, cppstd, libcxx=None):
+        settings = MockSettings({"arch": "x86_64",
+                                 "build_type": "Debug",
+                                 "os": os,
+                                 "compiler": compiler,
+                                 "compiler.version": version,
+                                 "compiler.cppstd": cppstd})
+        if libcxx:
+            settings.values["compiler.libcxx"] = libcxx
+        conanfile = MockConanfile(settings)
+        return conanfile
+
+    @parameterized.expand(["98", "11", "14", "17"])
+    def test_valid_min_cppstd_from_settings(self, cppstd):
+        """ valid_min_cppstd must accept cppstd less/equal than cppstd in settings
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", "17", "libstdc++")
+        self.assertTrue(valid_min_cppstd(conanfile, cppstd, False))
+
+    @parameterized.expand(["98", "11", "14"])
+    def test_valid_min_cppstd_from_outdated_settings(self, cppstd):
+        """ valid_min_cppstd returns False when cppstd is greater when supported on settings
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", cppstd, "libstdc++")
+        self.assertFalse(valid_min_cppstd(conanfile, "17", False))
+
+    @parameterized.expand(["98", "11", "14", "17"])
+    def test_valid_min_cppstd_from_settings_with_extension(self, cppstd):
+        """ valid_min_cppstd must returns True when current cppstd in settings has GNU extension and
+            extensions is enabled
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", "gnu17", "libstdc++")
+        self.assertTrue(valid_min_cppstd(conanfile, cppstd, True))
+
+        conanfile.settings.values["compiler.cppstd"] = "17"
+        self.assertFalse(valid_min_cppstd(conanfile, cppstd, True))
+
+    def test_valid_min_cppstd_unsupported_standard(self):
+        """ valid_min_cppstd must returns False when the compiler does not support a standard
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", None, "libstdc++")
+        self.assertFalse(valid_min_cppstd(conanfile, "42", False))
+
+    def test_valid_min_cppstd_gnu_compiler_extension(self):
+        """ valid_min_cppstd must returns False when current compiler does not support GNU extension
+            on Linux and extensions is required
+        """
+        conanfile = self._create_conanfile("gcc", "9", "Linux", None, "libstdc++")
+        with mock.patch("platform.system", mock.MagicMock(return_value="Linux")):
+            with mock.patch.object(OSInfo, '_get_linux_distro_info'):
+                with mock.patch("conans.client.tools.settings.cppstd_default", return_value="gnu1z"):
+                    self.assertFalse(valid_min_cppstd(conanfile, "20", True))
+
+    @parameterized.expand(["98", "11", "14", "17"])
+    def test_min_cppstd_mingw_windows(self, cppstd):
+        """ GNU extensions HAS effect on Windows when running a cross-building for Linux
+        """
+        with mock.patch("platform.system", mock.MagicMock(return_value="Windows")):
+            conanfile = self._create_conanfile("gcc", "9", "Linux", "gnu17", "libstdc++")
+            self.assertTrue(valid_min_cppstd(conanfile, cppstd, True))
+
+            conanfile.settings.values["compiler.cppstd"] = "17"
+            self.assertFalse(valid_min_cppstd(conanfile, cppstd, True))