Repository: conan-io/conan

Issue:
[feature] consider ability to apply/inject arbitrary patches to recipe without forking repository
### What is your suggestion?

(placeholder)

### Have you read the CONTRIBUTING guide?

- [X] I've read the CONTRIBUTING guide


Discussion:
I have been thinking a bit about this, and I think this is might have some important challenges:

- being able to inject arbitrary patches is equivalent to modifying ``conandata.yml`` on the fly. The same principle could be used to modify also the ``conandata.yml`` "sources" origin.
- This injection, applied to any recipe existing in Conan cache or Conan servers, to build some dependencies injecting patches, means that the ``recipe-revision`` is not changed at all, it cannot be changed, as that implies a whole new ``export``
- If the recipe-revision is not change at all, then there is no traceability, reproducibility or correct ``package_id`` computation, changing the applied patches will not trigger a new build, and even if the build was forced it will never know the corresponding source patches that were applied

So the only possibility that I think would make some sense is to have an extra ``conandata.yml`` location for ``conan export/create`` time from ``conan-center-index`` fork, similar to the ``settings_user.yml`` approach, when it is possible to load extra patches from another location. But I think this sounds a worse alternative than just adding the patches in the original ``conandata.yml`` and have a clear ``diff``. Was this the original idea of this ticket?



Patch:
diff --git a/conan/internal/model/conf.py b/conan/internal/model/conf.py
index 065af309854..ed3cf8ed7a7 100644
--- a/conan/internal/model/conf.py
+++ b/conan/internal/model/conf.py
@@ -33,6 +33,7 @@
     "core.sources:download_urls": "List of URLs to download backup sources from",
     "core.sources:upload_url": "Remote URL to upload backup sources to",
     "core.sources:exclude_urls": "URLs which will not be backed up",
+    "core.sources.patch:extra_path": "Extra path to search for patch files for conan create",
     # Package ID
     "core.package_id:default_unknown_mode": "By default, 'semver_mode'",
     "core.package_id:default_non_embed_mode": "By default, 'minor_mode'",
diff --git a/conan/tools/files/conandata.py b/conan/tools/files/conandata.py
index 3b1c47600d2..3fa41d59f1c 100644
--- a/conan/tools/files/conandata.py
+++ b/conan/tools/files/conandata.py
@@ -69,5 +69,8 @@ def trim_conandata(conanfile, raise_if_missing=True):
         if version_data is not None:
             result[k] = {version: version_data}
 
+    # Update the internal conanfile data too
+    conanfile.conan_data = result
+
     new_conandata_yml = yaml.safe_dump(result, default_flow_style=False)
     save(path, new_conandata_yml)
diff --git a/conan/tools/files/patches.py b/conan/tools/files/patches.py
index 5c35641d7d4..834a61b68e3 100644
--- a/conan/tools/files/patches.py
+++ b/conan/tools/files/patches.py
@@ -3,9 +3,11 @@
 import shutil
 
 import patch_ng
+import yaml
 
 from conan.errors import ConanException
-from conans.util.files import mkdir
+from conan.internal.paths import DATA_YML
+from conans.util.files import mkdir, load, save
 
 
 class PatchLogHandler(logging.Handler):
@@ -122,27 +124,60 @@ def export_conandata_patches(conanfile):
     if conanfile.conan_data is None:
         raise ConanException("conandata.yml not defined")
 
-    patches = conanfile.conan_data.get('patches')
-    if patches is None:
-        conanfile.output.info("export_conandata_patches(): No patches defined in conandata")
-        return
+    conanfile_patches = conanfile.conan_data.get('patches')
 
-    if isinstance(patches, dict):
-        assert conanfile.version, "Can only be exported if conanfile.version is already defined"
-        entries = patches.get(conanfile.version, [])
-        if entries is None:
-            conanfile.output.warning(f"export_conandata_patches(): No patches defined for version {conanfile.version} in conandata.yml")
+    def _handle_patches(patches, patches_folder):
+        if patches is None:
+            conanfile.output.info("export_conandata_patches(): No patches defined in conandata")
             return
-    elif isinstance(patches, list):
-        entries = patches
-    else:
-        raise ConanException("conandata.yml 'patches' should be a list or a dict {version: list}")
-    for it in entries:
-        patch_file = it.get("patch_file")
-        if patch_file:
-            src = os.path.join(conanfile.recipe_folder, patch_file)
-            dst = os.path.join(conanfile.export_sources_folder, patch_file)
-            if not os.path.exists(src):
-                raise ConanException(f"Patch file does not exist: '{src}'")
-            mkdir(os.path.dirname(dst))
-            shutil.copy2(src, dst)
+
+        if isinstance(patches, dict):
+            assert conanfile.version, "Can only be exported if conanfile.version is already defined"
+            entries = patches.get(conanfile.version, [])
+            if entries is None:
+                conanfile.output.warning("export_conandata_patches(): No patches defined for "
+                                         f"version {conanfile.version} in conandata.yml")
+                return
+        elif isinstance(patches, list):
+            entries = patches
+        else:
+            raise ConanException("conandata.yml 'patches' should be a list or a dict "
+                                 "{version: list}")
+        for it in entries:
+            patch_file = it.get("patch_file")
+            if patch_file:
+                src = os.path.join(patches_folder, patch_file)
+                dst = os.path.join(conanfile.export_sources_folder, patch_file)
+                if not os.path.exists(src):
+                    raise ConanException(f"Patch file does not exist: '{src}'")
+                mkdir(os.path.dirname(dst))
+                shutil.copy2(src, dst)
+        return entries
+
+    _handle_patches(conanfile_patches, conanfile.recipe_folder)
+
+    extra_path = conanfile.conf.get("core.sources.patch:extra_path")
+    if extra_path:
+        if not os.path.isdir(extra_path):
+            raise ConanException(f"Patches extra path '{extra_path}' does not exist")
+        pkg_path = os.path.join(extra_path, conanfile.name)
+        if not os.path.isdir(pkg_path):
+            return
+        data_path = os.path.join(pkg_path, DATA_YML)
+        try:
+            data = yaml.safe_load(load(data_path))
+        except Exception as e:
+            raise ConanException("Invalid yml format at {}: {}".format(data_path, e))
+        data = data or {}
+        conanfile.output.info(f"Applying extra patches 'core.sources.patch:extra_path': {data_path}")
+        new_patches = _handle_patches(data.get('patches'), pkg_path)
+
+        # Update the CONANDATA.YML
+        conanfile_patches = conanfile_patches or {}
+        conanfile_patches.setdefault(conanfile.version, []).extend(new_patches)
+
+        conanfile.conan_data['patches'] = conanfile_patches
+        # Saving in the EXPORT folder
+        conanfile_data_path = os.path.join(conanfile.export_folder, DATA_YML)
+        new_conandata_yml = yaml.safe_dump(conanfile.conan_data, default_flow_style=False)
+        save(conanfile_data_path, new_conandata_yml)


Test patch:
diff --git a/test/functional/tools/test_files.py b/test/functional/tools/test_files.py
index 44b0c27486c..9abc601e45e 100644
--- a/test/functional/tools/test_files.py
+++ b/test/functional/tools/test_files.py
@@ -6,8 +6,9 @@
 
 from conan.test.assets.genconanfile import GenConanfile
 from conan.test.utils.file_server import TestFileServer
+from conan.test.utils.test_files import temp_folder
 from conan.test.utils.tools import TestClient
-from conans.util.files import save
+from conans.util.files import save, load
 
 
 class MockPatchset:
@@ -365,7 +366,7 @@ def build(self):
     assert mock_patch_ng.apply_args[1:] == (0, False)
 
 
-def test_export_conandata_patches(mock_patch_ng):
+def test_export_conandata_patches():
     conanfile = textwrap.dedent("""
         import os
         from conan import ConanFile
@@ -402,7 +403,7 @@ def source(self):
     # wrong patches
     client.save({"conandata.yml": "patches: 123"})
     client.run("create .", assert_error=True)
-    assert "conandata.yml 'patches' should be a list or a dict"  in client.out
+    assert "conandata.yml 'patches' should be a list or a dict" in client.out
 
     # No patch found
     client.save({"conandata.yml": conandata_yml})
@@ -447,3 +448,60 @@ def build(self):
     client.save({"conandata.yml": conandata_yml, "conanfile.py": conanfile})
     client.run("create .")
     assert "No patches defined for version 1.0 in conandata.yml" in client.out
+
+
+@pytest.mark.parametrize("trim", [True, False])
+def test_export_conandata_patches_extra_origin(trim):
+    conanfile = textwrap.dedent(f"""
+        import os
+        from conan import ConanFile
+        from conan.tools.files import export_conandata_patches, load, trim_conandata
+
+        class Pkg(ConanFile):
+            name = "mypkg"
+            version = "1.0"
+
+            def export(self):
+                if {trim}:
+                    trim_conandata(self)
+
+            def layout(self):
+                self.folders.source = "source_subfolder"
+
+            def export_sources(self):
+                export_conandata_patches(self)
+
+            def source(self):
+                self.output.info(load(self, os.path.join(self.export_sources_folder,
+                                                         "patches/mypatch.patch")))
+        """)
+    client = TestClient(light=True)
+    patches_folder = temp_folder()
+    conandata_yml = textwrap.dedent("""
+        patches:
+            "1.0":
+                - patch_file: "patches/mypatch.patch"
+        """)
+    save(os.path.join(patches_folder, "mypkg", "conandata.yml"), conandata_yml)
+    save(os.path.join(patches_folder, "mypkg", "patches", "mypatch.patch"), "mypatch!!!")
+
+    pkg_conandata = textwrap.dedent("""\
+        patches:
+            "1.1":
+                - patch_file: "patches/mypatch2.patch"
+    """)
+    client.save({"conanfile.py": conanfile,
+                 "conandata.yml": pkg_conandata,
+                 "patches/mypatch2.patch": ""})
+    client.run(f'create . -cc core.sources.patch:extra_path="{patches_folder}"')
+    assert "mypkg/1.0: Applying extra patches" in client.out
+    assert "mypkg/1.0: mypatch!!!" in client.out
+
+    conandata = load(client.exported_layout().conandata())
+    assert "1.0" in conandata
+    assert "patch_file: patches/mypatch.patch" in conandata
+
+    if trim:
+        assert "1.1" not in conandata
+    else:
+        assert "1.1" in conandata