Skip to content

Commit

Permalink
[feature] Add tools (conan.tools) to apply patches (#8650)
Browse files Browse the repository at this point in the history
* [feature] Add tools (conan.tools) to apply patches

* Update conan/tools/files/patches.py

Co-authored-by: SSE4 <tomskside@gmail.com>

Co-authored-by: SSE4 <tomskside@gmail.com>
  • Loading branch information
jgsogo and SSE4 committed Mar 18, 2021
1 parent 309143d commit c336856
Show file tree
Hide file tree
Showing 5 changed files with 342 additions and 1 deletion.
1 change: 1 addition & 0 deletions 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
107 changes: 107 additions & 0 deletions 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)
78 changes: 77 additions & 1 deletion 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:
Expand Down Expand Up @@ -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)
Empty file.
157 changes: 157 additions & 0 deletions 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)

0 comments on commit c336856

Please sign in to comment.