Repository: conan-io/conan
Pull request: Add implements attribute in ConanFile to provide opt-in automatic management of some options and settings.
Changelog: Feature: Add `implements` attribute in ConanFile to provide automatic management of some options and settings.
Docs: https://github.com/conan-io/docs/pull/3303

Closes: https://github.com/conan-io/conan/issues/14308

Problem statement:
fPIC
### Environment details

* Operating System+version: Ubuntu 22.04
* Compiler+version: gcc 12.1
* Conan version: 2.0.8
* Python version: 3.10

Referring to commit 2af05b5: Why is fPIC automatically disabled when setting shared in the conanfile options?  This was a sneaky change that took a while to discover and presented an instability between Conan versions.

I still need fPIC enabled in order for the cmake toolchain to properly set the fPIC option.

### Steps to reproduce

_No response_

### Logs

_No response_

Hints from issue discussion:
Hi @Halmoni100 

Thanks for your report.

> Why is fPIC automatically disabled when setting shared in the conanfile options? This was a sneaky change that took a while to discover and presented an instability between Conan versions.

Conan 2.0.X patch releases main purpose is to stabilize the major Conan 2.0 release, that is a very big one. One of the most important aspects of the stabilization is to make sure that the right defaults are defined. In this regard, the removal of ``fPIC`` option when ``shared=True`` is a massively accepted and spread behavior. All recipes in ConanCenter do it (many hundreds of them, header-only libs won't define the ``shared`` option). It is already part of the ``conan new`` templates, and it is documented as such.

Sorry that it introduced some instability in your update, I'd like to understand better why this happened. When you define ``shared=True``, is it possible to still have ``fPIC=False`` and it will work? How would that be possible? If it is not possible, then maybe the reason is that this is also expected behavior. One of the main purposes of ``options`` is defining a different binary with a different ``package_id``. If an option cannot take more than one value, then the idea is that it shouldn't be an option. 

In Conan 2.0 design, if a recipe needs some flag activated for some reason when ``shared=True``, that would typically be a responsibility of the build system scripts, not Conan. Conan can define the ``CMAKE_POSITION_INDEPENDENT_CODE`` to ``ON`` or ``OFF`` when it is variable, but if for some reason, it is not an input configuration, not something that can really be set by the user (otherwise it doesn't work), then it should be defined inside the ``CMakeLists.txt``. It is also possible that a recipe explicitly patches or provide build system variables (via ``CMakeToolchain`` in ``generate()``) that inject or define that behavior, as a helper if the build system is more difficult to patch.

If you could please provide a bit more detail of the libraries configuration, the build, the CMake build scripts and the set-up, that would help understanding. Like, do you have something hardcoded like ``add_library(mylib STATIC ...)`` that will force a library to be static even if ``shared=True`` (that will define ``BUILD_SHARED_LIBS``)? Thanks!


I was not aware since I have not looked at recipes in the conancenter.  If the idea is that "if shared=True, then fPIC=True", therefore fPIC should not be set, then I think there should be clear documentation on this behavior.  In my opinion I think having the main program alter expected behavior with hidden custom paths-- esp. without warning-- is not great design from the user's point of view.
Hi @Halmoni100 - thank you for your reply.

I believe what you mention is already in our documentation, unless I'm missing something:
https://docs.conan.io/2/reference/conanfile/methods/configure.html?highlight=fpic#configure

> Likewise, for a package containing a library, the fPIC option really only applies when the library is compiled as a static library, but otherwise, the fPIC option doesn’t make sense, so it should be removed:

We're happy to amend or clarify the documentation if this is not sufficiently clear. 

Irrespective of Conan, the notion in other build tools is that shared libraries imply generating position independent code:
* CMake will do so by default for `SHARED` targets, as per their [documentation](https://cmake.org/cmake/help/latest/prop_tgt/POSITION_INDEPENDENT_CODE.html#prop_tgt:POSITION_INDEPENDENT_CODE)  - in these instances, Conan specifying it was rather redundant
* In versions of Ubuntu 16.10 or newer,  the compiler _itself_ (as distributed in the Ubuntu repositories) is set up to produce position independent code by default, rather than requiring a explicit flag, as per https://wiki.ubuntu.com/SecurityTeam/PIE. This is part of overall security measures that other distros have also adopted, and generally in most modern distros I'd expect `-fPIC` to be the default behaviour (also see https://stackoverflow.com/a/41311959) 

I'm surprised you've had issues, given that the defaults outside of Conan on modern distros is to already emit position independent code - if you could provide more details (steps to reproduce and logs with both versions of Conan, before and after the change), I'm happy to look into this 





Thanks for clarifying the default fpic behavior of build tools and compilers, I wasn't totally aware.

The issue is actually about matching the actual hashing behavior of conan with its expected behavior.  The hidden custom path of removing fpic produces a different hash, so I was very confused when this changed and was having build problems when synchronizing conan packages.

Yes I don't think the documentation is very clear given the fact that the behavior is a priori unexpected.
My personal preference would be to revert to the old expected option behavior and emit an error when (shared == True and fPIC == False)
Hi @Halmoni100, thanks for clarifying. I'm afraid doing what you suggest may not be possible either, that is, emit an error when shared=True and fpic is still an option and it still false. On some platforms / cpu architectures this may be perfectly valid, that is, it would create a shared library and that is not relocatable and would be loaded to a fixed location within the process virtual memory space. Thus, it may be perfectly valid to generate such a combination, should the recipe maintainer choose to enable that by retaining the fpic option when shared=True. It may be unusual these days, but it's not an impossible combination

Commit message: fix comment

Patch to review:
diff --git a/conan/tools/__init__.py b/conan/tools/__init__.py
index 06b817f9800..a8a45d35375 100644
--- a/conan/tools/__init__.py
+++ b/conan/tools/__init__.py
@@ -1,9 +1,4 @@
 from conans.model.build_info import CppInfo as _CppInfo
-from conan.tools.helpers import (
-    default_config_options,
-    default_configure,
-    default_package_id
-)
 
 
 def CppInfo(conanfile):
diff --git a/conan/tools/helpers.py b/conan/tools/helpers.py
deleted file mode 100644
index 5e9d40fe0f1..00000000000
--- a/conan/tools/helpers.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from conans.model.pkg_type import PackageType
-
-
-def default_config_options(conanfile):
-    if conanfile.settings.get_safe("os") == "Windows":
-        conanfile.options.rm_safe("fPIC")
-
-
-def default_configure(conanfile):
-    if conanfile.options.get_safe("header_only"):
-        conanfile.options.rm_safe("fPIC")
-        conanfile.options.rm_safe("shared")
-    elif conanfile.options.get_safe("shared"):
-        conanfile.options.rm_safe("fPIC")
-
-
-def default_package_id(conanfile):
-    if conanfile.options.get_safe("header_only") or conanfile.package_type is PackageType.HEADER:
-        conanfile.info.clear()
diff --git a/conans/client/conanfile/configure.py b/conans/client/conanfile/configure.py
index cabfb324df2..3957c263e35 100644
--- a/conans/client/conanfile/configure.py
+++ b/conans/client/conanfile/configure.py
@@ -1,7 +1,7 @@
 from conans.errors import conanfile_exception_formatter
 from conans.model.pkg_type import PackageType
 from conans.model.requires import BuildRequirements, TestRequirements, ToolRequirements
-from conan.tools import default_config_options, default_configure
+import conans.client.conanfile.implementations as implementations
 
 
 def run_configure_method(conanfile, down_options, profile_options, ref):
@@ -10,8 +10,9 @@ def run_configure_method(conanfile, down_options, profile_options, ref):
     if hasattr(conanfile, "config_options"):
         with conanfile_exception_formatter(conanfile, "config_options"):
             conanfile.config_options()
-    else:
-        default_config_options(conanfile)
+    elif conanfile.implements:
+        for implementation in conanfile.implements:
+            implementations.call(implementations, implementation, "config_options", conanfile)
 
     # Assign only the current package options values, but none of the dependencies
     is_consumer = conanfile._conan_is_consumer
@@ -20,8 +21,9 @@ def run_configure_method(conanfile, down_options, profile_options, ref):
     if hasattr(conanfile, "configure"):
         with conanfile_exception_formatter(conanfile, "configure"):
             conanfile.configure()
-    else:
-        default_configure(conanfile)
+    elif conanfile.implements:
+        for implementation in conanfile.implements:
+            implementations.call(implementations, implementation, "configure", conanfile)
 
     self_options, up_options = conanfile.options.get_upstream_options(down_options, ref,
                                                                       is_consumer)
diff --git a/conans/client/conanfile/implementations.py b/conans/client/conanfile/implementations.py
new file mode 100644
index 00000000000..c6458ce5586
--- /dev/null
+++ b/conans/client/conanfile/implementations.py
@@ -0,0 +1,43 @@
+import inspect
+
+from conans.model.pkg_type import PackageType
+
+
+def call(module, implementation_name, conanfile_method, *args, **kwargs):
+    # first check that we have at least one function in the module that starts
+    # with the implementation name, then, when calling to the function name composed
+    # with the conanfile_method name we pass on the exception
+    def _has_function_starting_with(module, prefix):
+        function_names = [name for name, _ in inspect.getmembers(module, inspect.isfunction)]
+        for name in function_names:
+            if name.startswith(prefix):
+                return True
+        return False
+
+    if not _has_function_starting_with(module, implementation_name):
+        raise Exception(f"'{implementation_name}' is not a valid 'implements' value.")
+
+    implementation_function_name = f"{implementation_name}_{conanfile_method}"
+    try:
+        implementation_function = getattr(module, implementation_function_name)
+        return implementation_function(*args, **kwargs)
+    except AttributeError:
+        pass
+
+
+def auto_shared_fpic_config_options(conanfile):
+    if conanfile.settings.get_safe("os") == "Windows":
+        conanfile.options.rm_safe("fPIC")
+
+
+def auto_shared_fpic_configure(conanfile):
+    if conanfile.options.get_safe("header_only"):
+        conanfile.options.rm_safe("fPIC")
+        conanfile.options.rm_safe("shared")
+    elif conanfile.options.get_safe("shared"):
+        conanfile.options.rm_safe("fPIC")
+
+
+def auto_header_only_package_id(conanfile):
+    if conanfile.options.get_safe("header_only") or conanfile.package_type is PackageType.HEADER:
+        conanfile.info.clear()
diff --git a/conans/client/graph/compute_pid.py b/conans/client/graph/compute_pid.py
index 66181e61e7a..a07639a963f 100644
--- a/conans/client/graph/compute_pid.py
+++ b/conans/client/graph/compute_pid.py
@@ -3,7 +3,7 @@
 from conans.errors import conanfile_exception_formatter, ConanInvalidConfiguration, \
     conanfile_remove_attr, ConanException
 from conans.model.info import ConanInfo, RequirementsInfo, RequirementInfo, PythonRequiresInfo
-from conan.tools import default_package_id
+import conans.client.conanfile.implementations as implementations
 
 
 def compute_package_id(node, new_config):
@@ -82,6 +82,8 @@ def run_validate_package_id(conanfile):
         with conanfile_exception_formatter(conanfile, "package_id"):
             with conanfile_remove_attr(conanfile, ['cpp_info', 'settings', 'options'], "package_id"):
                 conanfile.package_id()
-    else:
-        default_package_id(conanfile)
+    elif conanfile.implements:
+        for implementation in conanfile.implements:
+            implementations.call(implementations, implementation, "package_id", conanfile)
+
     conanfile.info.validate()
diff --git a/conans/model/conan_file.py b/conans/model/conan_file.py
index 43cd2f397ac..33847dc1477 100644
--- a/conans/model/conan_file.py
+++ b/conans/model/conan_file.py
@@ -48,6 +48,8 @@ class ConanFile:
     default_options = None
     package_type = None
 
+    implements = []
+
     provides = None
     deprecated = None
 
diff --git a/conans/test/functional/toolchains/cmake/test_cmake.py b/conans/test/functional/toolchains/cmake/test_cmake.py
index feeeef80a50..38f7319fd30 100644
--- a/conans/test/functional/toolchains/cmake/test_cmake.py
+++ b/conans/test/functional/toolchains/cmake/test_cmake.py
@@ -57,6 +57,7 @@ class App(ConanFile):
             generators = "CMakeDeps"
             options = {"shared": [True, False], "fPIC": [True, False]}
             default_options = {"shared": False, "fPIC": True}
+            implements = ["auto_shared_fpic", "auto_header_only"]
 
             def generate(self):
                 tc = CMakeToolchain(self)
@@ -395,6 +396,7 @@ def test_toolchain_linux(self, build_type, cppstd, arch, libcxx, shared):
                 "CMAKE_EXE_LINKER_FLAGS": arch_str,
                 "COMPILE_DEFINITIONS": defines,
                 # fPIC is managed automatically depending on the shared option value
+                # if implements = ["auto_shared_fpic", "auto_header_only"]
                 "CMAKE_POSITION_INDEPENDENT_CODE": "ON" if not shared else ""
                 }
 
diff --git a/conans/test/integration/options/test_configure_options.py b/conans/test/integration/options/test_configure_options.py
index f007fc835a4..5bfff4b95b8 100644
--- a/conans/test/integration/options/test_configure_options.py
+++ b/conans/test/integration/options/test_configure_options.py
@@ -27,8 +27,8 @@ class ConfigureOptionsTest(unittest.TestCase):
     ])
     def test_methods_not_defined(self, settings_os, shared, fpic, header_only, result):
         """
-        Test that options are managed automatically when methods config_otpions and configure are not
-        defined.
+        Test that options are managed automatically when methods config_options and configure are not
+        defined and implements = ["auto_shared_fpic", "auto_header_only"].
         Check that header only package gets its unique package ID.
         """
         client = TestClient()
@@ -39,6 +39,7 @@ class Pkg(ConanFile):
                settings = "os", "compiler", "arch", "build_type"
                options = {{"shared": [True, False], "fPIC": [True, False], "header_only": [True, False]}}
                default_options = {{"shared": {shared}, "fPIC": {fpic}, "header_only": {header_only}}}
+               implements = ["auto_shared_fpic", "auto_header_only"]
 
                def build(self):
                    shared = self.options.get_safe("shared")
@@ -73,7 +74,7 @@ def build(self):
     ])
     def test_optout(self, settings_os, shared, fpic, header_only, result):
         """
-        Test that options are not managed automatically when methods are defined.
+        Test that options are not managed automatically when methods are defined even if implements = ["auto_shared_fpic", "auto_header_only"]
         Check that header only package gets its unique package ID.
         """
         client = TestClient()
@@ -84,6 +85,7 @@ class Pkg(ConanFile):
                settings = "os", "compiler", "arch", "build_type"
                options = {{"shared": [True, False], "fPIC": [True, False], "header_only": [True, False]}}
                default_options = {{"shared": {shared}, "fPIC": {fpic}, "header_only": {header_only}}}
+               implements = ["auto_shared_fpic", "auto_header_only"]
 
                def config_options(self):
                    pass
@@ -104,53 +106,6 @@ def build(self):
         if header_only:
             self.assertIn("Package 'da39a3ee5e6b4b0d3255bfef95601890afd80709' created", client.out)
 
-    @parameterized.expand([
-        ["Linux", False, False, False, [False, False, False]],
-        ["Windows", False, False, False, [False, None, False]],
-        ["Windows", True, False, False, [True, None, False]],
-        ["Windows", False, False, True, [None, None, True]],
-        ["Linux", False, False, True, [None, None, True]],
-        ["Linux", True, True, False, [True, None, False]],
-        ["Linux", True, False, False, [True, None, False]],
-        ["Linux", True, True, True, [None, None, True]],
-        ["Linux", True, True, True, [None, None, True]],
-        ["Linux", False, True, False, [False, True, False]],
-        ["Linux", False, True, False, [False, True, False]],
-    ])
-    def test_methods_defined_explicit(self, settings_os, shared, fpic, header_only, result):
-        """
-        Test that options are managed when the tool is used in methods defined by user.
-        Check that header only package gets its unique package ID.
-        """
-        client = TestClient()
-        conanfile = textwrap.dedent(f"""\
-               from conan import ConanFile
-               from conan.tools import default_config_options, default_configure
-
-               class Pkg(ConanFile):
-                   settings = "os", "compiler", "arch", "build_type"
-                   options = {{"shared": [True, False], "fPIC": [True, False], "header_only": [True, False]}}
-                   default_options = {{"shared": {shared}, "fPIC": {fpic}, "header_only": {header_only}}}
-
-                   def config_options(self):
-                       default_config_options(self)
-
-                   def configure(self):
-                       default_configure(self)
-
-                   def build(self):
-                       shared = self.options.get_safe("shared")
-                       fpic = self.options.get_safe("fPIC")
-                       header_only = self.options.get_safe("header_only")
-                       self.output.info(f"shared: {{shared}}, fPIC: {{fpic}}, header only: {{header_only}}")
-                """)
-        client.save({"conanfile.py": conanfile})
-        client.run(f"create . --name=pkg --version=0.1 -s os={settings_os}")
-        result = f"shared: {result[0]}, fPIC: {result[1]}, header only: {result[2]}"
-        self.assertIn(result, client.out)
-        if header_only:
-            self.assertIn("Package 'da39a3ee5e6b4b0d3255bfef95601890afd80709' created", client.out)
-
     def test_header_package_type_pid(self):
         """
         Test that we get the pid for header only when package type is set to header-library
@@ -162,6 +117,7 @@ def test_header_package_type_pid(self):
                class Pkg(ConanFile):
                    settings = "os", "compiler", "arch", "build_type"
                    package_type = "header-library"
+                   implements = ["auto_shared_fpic", "auto_header_only"]
 
                 """)
         client.save({"conanfile.py": conanfile})
diff --git a/conans/test/integration/package_id/test_default_package_id.py b/conans/test/integration/package_id/test_default_package_id.py
index 627756567f4..323c8b48bf7 100644
--- a/conans/test/integration/package_id/test_default_package_id.py
+++ b/conans/test/integration/package_id/test_default_package_id.py
@@ -23,9 +23,9 @@ def test_default_package_id_options(typedep, typeconsumer, different_id):
     """
     c = TestClient()
     dep = GenConanfile("dep", "0.1").with_option("myopt", [True, False]) \
-        .with_package_type(typedep)
+        .with_package_type(typedep).with_class_attribute('implements = ["auto_shared_fpic", "auto_header_only"]')
     consumer = GenConanfile("consumer", "0.1").with_requires("dep/0.1")\
-        .with_package_type(typeconsumer)
+        .with_package_type(typeconsumer).with_class_attribute('implements = ["auto_shared_fpic", "auto_header_only"]')
 
     c.save({"dep/conanfile.py": dep,
             "consumer/conanfile.py": consumer})


Code review comments:
diff_hunk: @@ -0,0 +1,43 @@
+import inspect
+
+from conans.model.pkg_type import PackageType
+
+
+def call(module, implementation_name, conanfile_method, *args, **kwargs):
+    # first check that we have at least one function in the module that starts
+    # with the implementation name, then, when calling to the function name composed
+    # with the conanfile_method name we pass on the exception
+    def _has_function_starting_with(module, prefix):
path: conans/client/conanfile/implementations.py
text: @author:
Maybe we want this to be checked earlier

@user1:
Yes, seems a bit too much of extra checking, for every recipe loading in the graph (hundreds of them), this will be repeated over and over.

I am fine with no error checking for the initial implementation

diff_hunk: @@ -10,8 +10,9 @@ def run_configure_method(conanfile, down_options, profile_options, ref):
     if hasattr(conanfile, "config_options"):
         with conanfile_exception_formatter(conanfile, "config_options"):
             conanfile.config_options()
-    else:
-        default_config_options(conanfile)
+    elif conanfile.implements:
path: conans/client/conanfile/configure.py
text: @user1:
```suggestion
    elif "auto_shared_fpic" in conanfile.implements:
         auto_shared_fpic_config_options(conanfile)
```
I think I'd have preferred a minimal, explicit implementation to start with this feature, and only generalize a bit later when there is more evidence.

@user1:
(But I am fine with it, not a big issue)

@author:
Yes, I did it this way in case we wanted to respect the order of execution stated in the list, in fact, I prefer the minimal, explicit implementation 

@user1:
I'd say lets hardcode the order, we know it works. If in the future order-defined application of ``implements`` is really a thing... maybe they should be explicit in their methods.

Yeah, I think we shouldn't care about the user order at all, it would be another point of variability and issues.