Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feature] Add tools (conan.tools) to apply patches #8650

Merged
merged 2 commits into from Mar 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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)