diff --git a/conan/tools/files/__init__.py b/conan/tools/files/__init__.py index 007aaef3fb4..bc0dd25b5b9 100644 --- a/conan/tools/files/__init__.py +++ b/conan/tools/files/__init__.py @@ -1 +1,2 @@ from conan.tools.files.files import load, save, mkdir, ftp_download, download, get +from conan.tools.files.patches import patch, apply_conandata_patches diff --git a/conan/tools/files/patches.py b/conan/tools/files/patches.py new file mode 100644 index 00000000000..bc5f17a2c00 --- /dev/null +++ b/conan/tools/files/patches.py @@ -0,0 +1,107 @@ +import logging + +import patch_ng + +from conans.errors import ConanException + +try: + from collections.abc import Iterable +except ImportError: + from collections import Iterable + + +class PatchLogHandler(logging.Handler): + def __init__(self, conanfile, patch_file): + logging.Handler.__init__(self, logging.DEBUG) + self._output = conanfile.output + self.patchname = patch_file or "patch_ng" + + def emit(self, record): + logstr = self.format(record) + if record.levelno == logging.WARN: + self._output.warn("%s: %s" % (self.patchname, logstr)) + else: + self._output.info("%s: %s" % (self.patchname, logstr)) + + +def patch(conanfile, base_path=None, patch_file=None, patch_string=None, + strip=0, fuzz=False, **kwargs): + """ Applies a diff from file (patch_file) or string (patch_string) + in base_path directory or current dir if None + :param base_path: Base path where the patch should be applied. + :param patch_file: Patch file that should be applied. + :param patch_string: Patch string that should be applied. + :param strip: Number of folders to be stripped from the path. + :param output: Stream object. + :param fuzz: Should accept fuzzy patches. + :param kwargs: Extra parameters that can be added and will contribute to output information + """ + + patch_type = kwargs.get('patch_type') + patch_description = kwargs.get('patch_description') + if patch_type or patch_description: + patch_type_str = ' ({})'.format(patch_type) if patch_type else '' + patch_description_str = ': {}'.format(patch_description) if patch_description else '' + conanfile.output.info('Apply patch{}{}'.format(patch_type_str, patch_description_str)) + + patchlog = logging.getLogger("patch_ng") + patchlog.handlers = [] + patchlog.addHandler(PatchLogHandler(conanfile, patch_file)) + + if patch_file: + patchset = patch_ng.fromfile(patch_file) + else: + patchset = patch_ng.fromstring(patch_string.encode()) + + if not patchset: + raise ConanException("Failed to parse patch: %s" % (patch_file if patch_file else "string")) + + if not patchset.apply(root=base_path, strip=strip, fuzz=fuzz): + raise ConanException("Failed to apply patch: %s" % patch_file) + + +def apply_conandata_patches(conanfile): + """ + Applies patches stored in 'conanfile.conan_data' (read from 'conandata.yml' file). It will apply + all the patches under 'patches' entry that matches the given 'conanfile.version'. If versions are + not defined in 'conandata.yml' it will apply all the patches directly under 'patches' keyword. + + Example of 'conandata.yml' without versions defined: + + ``` + patches: + - patch_file: "patches/0001-buildflatbuffers-cmake.patch" + base_path: "source_subfolder" + - patch_file: "patches/0002-implicit-copy-constructor.patch" + base_path: "source_subfolder" + patch_type: backport + patch_source: https://github.com/google/flatbuffers/pull/5650 + patch_description: Needed to build with modern clang compilers (adapted to 1.11.0 tagged sources). + ``` + + Example of 'conandata.yml' with different patches for different versions: + ``` + patches: + "1.11.0": + - patch_file: "patches/0001-buildflatbuffers-cmake.patch" + base_path: "source_subfolder" + - patch_file: "patches/0002-implicit-copy-constructor.patch" + base_path: "source_subfolder" + patch_type: backport + patch_source: https://github.com/google/flatbuffers/pull/5650 + patch_description: Needed to build with modern clang compilers (adapted to 1.11.0 tagged sources). + "1.12.0": + - patch_file: "patches/0001-buildflatbuffers-cmake.patch" + base_path: "source_subfolder" + ``` + """ + + patches = conanfile.conan_data.get('patches') + if isinstance(patches, dict): + assert conanfile.version, "Can only be applied if conanfile.version is already defined" + entries = patches.get(conanfile.version, []) + for it in entries: + patch(conanfile, **it) + elif isinstance(patches, Iterable): + for it in patches: + patch(conanfile, **it) diff --git a/conans/test/functional/tools/test_files.py b/conans/test/functional/tools/test_files.py index a680dd5a066..b8b16044398 100644 --- a/conans/test/functional/tools/test_files.py +++ b/conans/test/functional/tools/test_files.py @@ -1,12 +1,30 @@ import os import textwrap +import patch_ng +import pytest from bottle import static_file +from conans.test.assets.genconanfile import GenConanfile from conans.test.utils.test_files import temp_folder from conans.test.utils.tools import TestClient, StoppableThreadBottle from conans.util.files import save -from conans.test.assets.genconanfile import GenConanfile + + +class MockPatchset: + apply_args = None + + def apply(self, root, strip, fuzz): + self.apply_args = (root, strip, fuzz) + return True + + +@pytest.fixture +def mock_patch_ng(monkeypatch): + mock = MockPatchset() + + monkeypatch.setattr(patch_ng, "fromfile", lambda _: mock) + return mock class TestConanToolFiles: @@ -69,3 +87,61 @@ def source(self): client.save({"conanfile.py": conanfile}) client.save({"profile": profile}) client.run("create . -pr=profile") + + +def test_patch(mock_patch_ng): + conanfile = textwrap.dedent(""" + from conans import ConanFile + from conan.tools.files import patch + + class Pkg(ConanFile): + name = "mypkg" + version = "1.0" + + def build(self): + patch(self, patch_file='path/to/patch-file', patch_type='security') + """) + + client = TestClient() + client.save({"conanfile.py": conanfile}) + client.run('create .') + + assert mock_patch_ng.apply_args == (None, 0, False) + assert 'mypkg/1.0: Apply patch (security)' in str(client.out) + + +def test_apply_conandata_patches(mock_patch_ng): + conanfile = textwrap.dedent(""" + from conans import ConanFile + from conan.tools.files import apply_conandata_patches + + class Pkg(ConanFile): + name = "mypkg" + version = "1.11.0" + + def build(self): + apply_conandata_patches(self) + """) + conandata_yml = textwrap.dedent(""" + patches: + "1.11.0": + - patch_file: "patches/0001-buildflatbuffers-cmake.patch" + base_path: "source_subfolder" + - patch_file: "patches/0002-implicit-copy-constructor.patch" + base_path: "source_subfolder" + patch_type: backport + patch_source: https://github.com/google/flatbuffers/pull/5650 + patch_description: Needed to build with modern clang compilers. + "1.12.0": + - patch_file: "patches/0001-buildflatbuffers-cmake.patch" + base_path: "source_subfolder" + """) + + client = TestClient() + client.save({'conanfile.py': conanfile, + 'conandata.yml': conandata_yml}) + client.run('create .') + + assert mock_patch_ng.apply_args == ('source_subfolder', 0, False) + assert 'mypkg/1.11.0: Apply patch (backport): Needed to build with modern' \ + ' clang compilers.' in str(client.out) diff --git a/conans/test/unittests/tools/files/__init__.py b/conans/test/unittests/tools/files/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/conans/test/unittests/tools/files/test_patches.py b/conans/test/unittests/tools/files/test_patches.py new file mode 100644 index 00000000000..8c055de80bc --- /dev/null +++ b/conans/test/unittests/tools/files/test_patches.py @@ -0,0 +1,157 @@ +import patch_ng +import pytest + +from conan.tools.files import patch, apply_conandata_patches +from conans.errors import ConanException +from conans.test.utils.mocks import ConanFileMock + + +class MockPatchset: + filename = None + string = None + apply_args = None + + def apply(self, root, strip, fuzz): + self.apply_args = (root, strip, fuzz) + return True + + +@pytest.fixture +def mock_patch_ng(monkeypatch): + mock = MockPatchset() + + def mock_fromfile(filename): + mock.filename = filename + return mock + + def mock_fromstring(string): + mock.string = string + return mock + + monkeypatch.setattr(patch_ng, "fromfile", mock_fromfile) + monkeypatch.setattr(patch_ng, "fromstring", mock_fromstring) + return mock + + +def test_single_patch_file(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_file='patch-file') + assert mock_patch_ng.filename == 'patch-file' + assert mock_patch_ng.string is None + assert mock_patch_ng.apply_args == (None, 0, False) + assert len(str(conanfile.output)) == 0 + + +def test_single_patch_string(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_string='patch_string') + assert mock_patch_ng.string == b'patch_string' + assert mock_patch_ng.filename is None + assert mock_patch_ng.apply_args == (None, 0, False) + assert len(str(conanfile.output)) == 0 + + +def test_single_patch_arguments(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_file='patch-file', base_path='root', strip=23, fuzz=True) + assert mock_patch_ng.filename == 'patch-file' + assert mock_patch_ng.apply_args == ('root', 23, True) + assert len(str(conanfile.output)) == 0 + + +def test_single_patch_type(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_file='patch-file', patch_type='patch_type') + assert 'Apply patch (patch_type)\n' == str(conanfile.output) + + +def test_single_patch_description(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_file='patch-file', patch_description='patch_description') + assert 'Apply patch: patch_description\n' == str(conanfile.output) + + +def test_single_patch_extra_fields(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + patch(conanfile, patch_file='patch-file', patch_type='patch_type', + patch_description='patch_description') + assert 'Apply patch (patch_type): patch_description\n' == str(conanfile.output) + + +def test_single_no_patchset(monkeypatch): + monkeypatch.setattr(patch_ng, "fromfile", lambda _: None) + + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + with pytest.raises(ConanException) as excinfo: + patch(conanfile, patch_file='patch-file-failed') + assert 'Failed to parse patch: patch-file-failed' == str(excinfo.value) + + +def test_single_apply_fail(monkeypatch): + class MockedApply: + def apply(self, *args, **kwargs): + return False + + monkeypatch.setattr(patch_ng, "fromfile", lambda _: MockedApply()) + + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + with pytest.raises(ConanException) as excinfo: + patch(conanfile, patch_file='patch-file-failed') + assert 'Failed to apply patch: patch-file-failed' == str(excinfo.value) + + +def test_multiple_no_version(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + conanfile.conan_data = {'patches': [ + {'patch_file': 'patches/0001-buildflatbuffers-cmake.patch', + 'base_path': 'source_subfolder', }, + {'patch_file': 'patches/0002-implicit-copy-constructor.patch', + 'base_path': 'source_subfolder', + 'patch_type': 'backport', + 'patch_source': 'https://github.com/google/flatbuffers/pull/5650', + 'patch_description': 'Needed to build with modern clang compilers.'} + ]} + apply_conandata_patches(conanfile) + assert 'Apply patch (backport): Needed to build with modern clang compilers.\n' \ + == str(conanfile.output) + + +def test_multiple_with_version(mock_patch_ng): + conanfile = ConanFileMock() + conanfile.display_name = 'mocked/ref' + conanfile.conan_data = {'patches': { + "1.11.0": [ + {'patch_file': 'patches/0001-buildflatbuffers-cmake.patch', + 'base_path': 'source_subfolder', }, + {'patch_file': 'patches/0002-implicit-copy-constructor.patch', + 'base_path': 'source_subfolder', + 'patch_type': 'backport', + 'patch_source': 'https://github.com/google/flatbuffers/pull/5650', + 'patch_description': 'Needed to build with modern clang compilers.'} + ], + "1.12.0": [ + {'patch_file': 'patches/0001-buildflatbuffers-cmake.patch', + 'base_path': 'source_subfolder', }, + ]}} + + with pytest.raises(AssertionError) as excinfo: + apply_conandata_patches(conanfile) + assert 'Can only be applied if conanfile.version is already defined' == str(excinfo.value) + + conanfile.version = "1.2.11" + apply_conandata_patches(conanfile) + assert len(str(conanfile.output)) == 0 + + conanfile.version = "1.11.0" + apply_conandata_patches(conanfile) + assert 'Apply patch (backport): Needed to build with modern clang compilers.\n' \ + == str(conanfile.output)