Commit 23421106 authored by Kazuhiko Shiozaki's avatar Kazuhiko Shiozaki Committed by Xavier Thompson

[feat] zc.recipe.egg: Support on the fly patches.

- Support on the fly patches in zc.recipe.egg by ``EGGNAME-patches``,
  ``EGGNAME-patch-options``, ``EGGNAME-patch-binary`` (or
  ``patch-binary``) and ``EGGNAME-patch-revision`` options.

- Support on the fly patches in zc.recipe.egg:custom by ``patches``,
  ``patch-options``, ``patch-binary`` and ``patch-revision`` options.
  (options ``EGGNAME-*`` are also supported as well).

Specified patches are automatically applied on required eggs as well.

This fixes cache of patches.

Clean-up + fix issue found at slapos!1674
parent 8a0eed18
...@@ -81,6 +81,9 @@ is_source_encoding_line = re.compile(r'coding[:=]\s*([-\w.]+)').search ...@@ -81,6 +81,9 @@ is_source_encoding_line = re.compile(r'coding[:=]\s*([-\w.]+)').search
is_win32 = sys.platform == 'win32' is_win32 = sys.platform == 'win32'
is_jython = sys.platform.startswith('java') is_jython = sys.platform.startswith('java')
PATCH_MARKER = 'SlapOSPatched'
orig_versions_re = re.compile(r'[+\-]%s\d+' % PATCH_MARKER)
if is_jython: if is_jython:
import java.lang.System import java.lang.System
jython_os_name = (java.lang.System.getProperties()['os.name']).lower() jython_os_name = (java.lang.System.getProperties()['os.name']).lower()
...@@ -464,6 +467,11 @@ class Installer(object): ...@@ -464,6 +467,11 @@ class Installer(object):
zc.buildout.rmtree.rmtree(tmp) zc.buildout.rmtree.rmtree(tmp)
def _obtain(self, requirement, source=None): def _obtain(self, requirement, source=None):
# get the non-patched version
req = str(requirement)
if PATCH_MARKER in req:
requirement = pkg_resources.Requirement.parse(re.sub(orig_versions_re, '', req))
# initialize out index for this project: # initialize out index for this project:
index = self._index index = self._index
...@@ -676,7 +684,7 @@ class Installer(object): ...@@ -676,7 +684,7 @@ class Installer(object):
return requirement return requirement
def install(self, specs, working_set=None): def install(self, specs, working_set=None, patch_dict=None):
logger.debug('Installing %s.', repr(specs)[1:-1]) logger.debug('Installing %s.', repr(specs)[1:-1])
__doing__ = _doing_list, self._requirements_and_constraints __doing__ = _doing_list, self._requirements_and_constraints
...@@ -700,6 +708,9 @@ class Installer(object): ...@@ -700,6 +708,9 @@ class Installer(object):
ws = working_set ws = working_set
for requirement in requirements: for requirement in requirements:
if patch_dict and requirement.project_name in patch_dict:
self._env.scan(
self.build(str(requirement), {}, patch_dict=patch_dict))
for dist in self._get_dist(requirement, ws): for dist in self._get_dist(requirement, ws):
self._maybe_add_setuptools(ws, dist) self._maybe_add_setuptools(ws, dist)
...@@ -752,6 +763,9 @@ class Installer(object): ...@@ -752,6 +763,9 @@ class Installer(object):
else: else:
logger.debug('Adding required %r', str(req)) logger.debug('Adding required %r', str(req))
self._log_requirement(ws, req) self._log_requirement(ws, req)
if patch_dict and req.project_name in patch_dict:
self._env.scan(
self.build(str(req), {}, patch_dict=patch_dict))
for dist in self._get_dist(req, ws): for dist in self._get_dist(req, ws):
self._maybe_add_setuptools(ws, dist) self._maybe_add_setuptools(ws, dist)
if dist not in req: if dist not in req:
...@@ -798,7 +812,7 @@ class Installer(object): ...@@ -798,7 +812,7 @@ class Installer(object):
processed[req] = True processed[req] = True
return ws return ws
def build(self, spec, build_ext): def build(self, spec, build_ext, patch_dict=None):
requirement = self._constrain(pkg_resources.Requirement.parse(spec)) requirement = self._constrain(pkg_resources.Requirement.parse(spec))
...@@ -849,12 +863,31 @@ class Installer(object): ...@@ -849,12 +863,31 @@ class Installer(object):
) )
base = os.path.dirname(setups[0]) base = os.path.dirname(setups[0])
setup_cfg_dict = {'build_ext':build_ext}
if patch_dict:
setup_cfg_dict.update(
{'egg_info':{'tag_build':'+%s%03d' % (PATCH_MARKER,
patch_dict['patch_revision'])}})
download = zc.buildout.download.Download(
cache=tmp, hash_name=True)
patch_dict = patch_dict[dist.project_name]
for patch in patch_dict['patches']:
url, md5sum = (patch.strip().split('#', 1) + [''])[:2]
path, is_temp = download(url, md5sum=md5sum or None)
args = [patch_dict['patch_binary']]
args += patch_dict['patch_options']
popen = subprocess.Popen(args,
cwd=base, stdin=open(path))
popen.communicate()
if popen.returncode:
raise subprocess.CalledProcessError(
popen.returncode, ' '.join(args))
setup_cfg = os.path.join(base, 'setup.cfg') setup_cfg = os.path.join(base, 'setup.cfg')
if not os.path.exists(setup_cfg): if not os.path.exists(setup_cfg):
f = open(setup_cfg, 'w') f = open(setup_cfg, 'w')
f.close() f.close()
setuptools.command.setopt.edit_config( setuptools.command.setopt.edit_config(
setup_cfg, dict(build_ext=build_ext)) setup_cfg, setup_cfg_dict)
dists = self._call_pip_wheel(base, self._dest, dist) dists = self._call_pip_wheel(base, self._dest, dist)
...@@ -974,6 +1007,7 @@ def install(specs, dest, ...@@ -974,6 +1007,7 @@ def install(specs, dest,
allowed_eggs_from_site_packages=None, allowed_eggs_from_site_packages=None,
check_picked=True, check_picked=True,
allow_unknown_extras=False, allow_unknown_extras=False,
patch_dict=None,
): ):
assert executable == sys.executable, (executable, sys.executable) assert executable == sys.executable, (executable, sys.executable)
assert include_site_packages is None assert include_site_packages is None
...@@ -985,18 +1019,19 @@ def install(specs, dest, ...@@ -985,18 +1019,19 @@ def install(specs, dest,
allow_hosts=allow_hosts, allow_hosts=allow_hosts,
check_picked=check_picked, check_picked=check_picked,
allow_unknown_extras=allow_unknown_extras) allow_unknown_extras=allow_unknown_extras)
return installer.install(specs, working_set) return installer.install(specs, working_set, patch_dict=patch_dict)
def build(spec, dest, build_ext, def build(spec, dest, build_ext,
links=(), index=None, links=(), index=None,
executable=sys.executable, executable=sys.executable,
path=None, newest=True, versions=None, allow_hosts=('*',)): path=None, newest=True, versions=None, allow_hosts=('*',),
patch_dict=None):
assert executable == sys.executable, (executable, sys.executable) assert executable == sys.executable, (executable, sys.executable)
installer = Installer(dest, links, index, executable, installer = Installer(dest, links, index, executable,
True, path, newest, True, path, newest,
versions, allow_hosts=allow_hosts) versions, allow_hosts=allow_hosts)
return installer.build(spec, build_ext) return installer.build(spec, build_ext, patch_dict=patch_dict)
def _rm(*paths): def _rm(*paths):
......
...@@ -9,6 +9,19 @@ eggs ...@@ -9,6 +9,19 @@ eggs
requirement strings. Each string must be given on a separate requirement strings. Each string must be given on a separate
line. line.
patch-binary
The path to the patch executable.
EGGNAME-patches
A new-line separated list of patchs to apply when building.
EGGNAME-patch-options
Options to give to the patch program when applying patches.
EGGNAME-patch-revision
An integer to specify the revision (default is the number of
patches).
find-links find-links
A list of URLs, files, or directories to search for distributions. A list of URLs, files, or directories to search for distributions.
......
...@@ -15,9 +15,12 @@ ...@@ -15,9 +15,12 @@
""" """
import logging import logging
import os import os
import re
import sys import sys
import pkg_resources
import zc.buildout.easy_install import zc.buildout.easy_install
from .egg import _get_patch_dict
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -70,6 +73,10 @@ class Base: ...@@ -70,6 +73,10 @@ class Base:
except KeyError: except KeyError:
pass pass
def _get_patch_dict(self, options, distribution):
req, = pkg_resources.parse_requirements(distribution)
return _get_patch_dict(options, [req.project_name])
class Custom(Base): class Custom(Base):
...@@ -127,10 +134,11 @@ class Custom(Base): ...@@ -127,10 +134,11 @@ class Custom(Base):
extra_path = os.pathsep.join(ws.entries) extra_path = os.pathsep.join(ws.entries)
self.environment['PYTHONEXTRAPATH'] = os.environ['PYTHONEXTRAPATH'] = extra_path self.environment['PYTHONEXTRAPATH'] = os.environ['PYTHONEXTRAPATH'] = extra_path
patch_dict = self._get_patch_dict(options, distribution)
return zc.buildout.easy_install.build( return zc.buildout.easy_install.build(
distribution, options['_d'], self.build_ext, distribution, options['_d'], self.build_ext,
self.links, self.index, sys.executable, self.links, self.index, sys.executable,
[options['_e']], newest=self.newest, [options['_e']], newest=self.newest, patch_dict=patch_dict,
) )
......
...@@ -24,6 +24,19 @@ setup-eggs ...@@ -24,6 +24,19 @@ setup-eggs
A new-line separated list of eggs that need to be installed A new-line separated list of eggs that need to be installed
beforehand. It is useful to meet the `setup_requires` requirement. beforehand. It is useful to meet the `setup_requires` requirement.
patch-binary
The path to the patch executable.
patches
A new-line separated list of patchs to apply when building.
patch-options
Options to give to the patch program when applying patches.
patch-revision
An integer to specify the revision (default is the number of
patches).
define define
A comma-separated list of names of C preprocessor variables to A comma-separated list of names of C preprocessor variables to
define. define.
......
...@@ -23,6 +23,24 @@ import zc.buildout.easy_install ...@@ -23,6 +23,24 @@ import zc.buildout.easy_install
from zc.buildout.buildout import bool_option from zc.buildout.buildout import bool_option
def _get_patch_dict(options, eggs):
patch_dict = {}
def get_option(key, default):
if len(eggs) == 1 or key != 'patches':
default = options.get(key, default)
return options.get('%s-%s' % (egg, key), default)
for egg in eggs:
patches = list(filter(None, map(str.strip,
get_option('patches', '').splitlines())))
if patches:
patch_dict[egg] = {
'patches': patches,
'patch_options': get_option('patch-options', '-p0').split(),
'patch_binary': get_option('patch-binary', 'patch'),
'patch_revision': len(patches),
}
return patch_dict
class Eggs(object): class Eggs(object):
...@@ -57,6 +75,10 @@ class Eggs(object): ...@@ -57,6 +75,10 @@ class Eggs(object):
options['develop-eggs-directory'] = b_options['develop-eggs-directory'] options['develop-eggs-directory'] = b_options['develop-eggs-directory']
options['_d'] = options['develop-eggs-directory'] # backward compat. options['_d'] = options['develop-eggs-directory'] # backward compat.
def _get_patch_dict(self, options):
egg_list = [x[:-8] for x in options if x.endswith('-patches')]
return _get_patch_dict(options, egg_list)
def working_set(self, extra=()): def working_set(self, extra=()):
"""Separate method to just get the working set """Separate method to just get the working set
...@@ -134,6 +156,7 @@ class Eggs(object): ...@@ -134,6 +156,7 @@ class Eggs(object):
[develop_eggs_dir, eggs_dir] [develop_eggs_dir, eggs_dir]
) )
else: else:
patch_dict = self._get_patch_dict(self.options)
ws = zc.buildout.easy_install.install( ws = zc.buildout.easy_install.install(
distributions, eggs_dir, distributions, eggs_dir,
links=links, links=links,
...@@ -141,7 +164,8 @@ class Eggs(object): ...@@ -141,7 +164,8 @@ class Eggs(object):
path=[develop_eggs_dir], path=[develop_eggs_dir],
newest=newest, newest=newest,
allow_hosts=allow_hosts, allow_hosts=allow_hosts,
allow_unknown_extras=allow_unknown_extras) allow_unknown_extras=allow_unknown_extras,
patch_dict=patch_dict)
ws = zc.buildout.easy_install.sort_working_set( ws = zc.buildout.easy_install.sort_working_set(
ws, buildout_dir, eggs_dir, develop_eggs_dir ws, buildout_dir, eggs_dir, develop_eggs_dir
) )
......
Patching eggs before installation
---------------------------------
The SlapOS extensions of ``zc.recipe.egg`` supports applying patches before installing eggs.
The syntax is to use a version with the magic string ``SlapOSPatched`` plus the number of
patches to apply.
Let's use a patch for demoneeded egg:
>>> write(sample_buildout, 'demoneeded.patch',
... r"""diff -ru before/demoneeded-1.1/eggrecipedemoneeded.py after/demoneeded-1.1/eggrecipedemoneeded.py
... --- before/demoneeded-1.1/eggrecipedemoneeded.py 2020-09-08 09:27:36.000000000 +0200
... +++ after/demoneeded-1.1/eggrecipedemoneeded.py 2020-09-08 09:46:16.482243822 +0200
... @@ -1,3 +1,3 @@
... -y=1
... +y="patched demoneeded"
... def f():
... pass
... \ No newline at end of file
... """)
First, we install demoneeded directly:
>>> write(sample_buildout, 'buildout.cfg',
... """
... [buildout]
... parts = demoneeded
...
... [demoneeded]
... recipe = zc.recipe.egg:eggs
... eggs = demoneeded
... find-links = %(server)s
... index = %(server)s/index
... demoneeded-patches =
... ./demoneeded.patch#4b8ad56711dd0d898a2b7957e9604079
... demoneeded-patch-options = -p2
...
... [versions]
... demoneeded = 1.1+SlapOSPatched001
... """ % dict(server=link_server))
When running buildout, we have a warning that a different version is installed, but that's not fatal.
>>> print_(system(buildout), end='')
Installing demoneeded.
patching file eggrecipedemoneeded.py
Installing demoneeded 1.1
Caused installation of a distribution:
demoneeded 1.1+slapospatched001
with a different version.
The installed egg has the slapospatched001 marker
>>> ls(sample_buildout, 'eggs')
d demoneeded-1.1+slapospatched001-pyN.N.egg
...pip...
...setuptools...
...wheel...
zc.buildout.egg
The code of the egg has been patched:
>>> import glob
>>> import os.path
>>> cat(glob.glob(os.path.join(sample_buildout, 'eggs', 'demoneeded-1.1+slapospatched001*', 'eggrecipedemoneeded.py'))[0])
y="patched demoneeded"
def f():
pass
Reset the state and also remove the installed egg
>>> remove('.installed.cfg')
>>> rmdir(glob.glob(os.path.join(sample_buildout, 'eggs', 'demoneeded-1.1+slapospatched001*'))[0])
In the previous example we applied patches to an egg installed directly, but
the same technique can be used to apply patches on eggs installed as dependencies.
In this example we install demo and apply a patch to demoneeded, which is a dependency to demo.
>>> write(sample_buildout, 'buildout.cfg',
... """
... [buildout]
... parts = demo
...
... [demo]
... recipe = zc.recipe.egg
... eggs = demo
... find-links = %(server)s
... index = %(server)s/index
... demoneeded-patches =
... ./demoneeded.patch#4b8ad56711dd0d898a2b7957e9604079
... demoneeded-patch-options = -p2
...
... [versions]
... demoneeded = 1.1+SlapOSPatched001
... """ % dict(server=link_server))
When running buildout, we also have that warning that a different version is installed.
>>> print_(system(buildout), end='')
Installing demo.
Getting distribution for 'demo'.
Got demo 0.3.
patching file eggrecipedemoneeded.py
Installing demoneeded 1.1
Caused installation of a distribution:
demoneeded 1.1+slapospatched001
with a different version.
Generated script '/sample-buildout/bin/demo'.
The installed egg has the slapospatched001 marker
>>> ls(sample_buildout, 'eggs')
d demo-0.3-pyN.N.egg
d demoneeded-1.1+slapospatched001-pyN.N.egg
...pip...
...setuptools...
...wheel...
zc.buildout.egg
If we run the demo script we see the patch was applied:
>>> print_(system(join(sample_buildout, 'bin', 'demo')), end='')
3 patched demoneeded
...@@ -106,6 +106,28 @@ def test_suite(): ...@@ -106,6 +106,28 @@ def test_suite():
''), ''),
]) ])
), ),
doctest.DocFileSuite(
'patches.rst',
setUp=setUp, tearDown=zc.buildout.testing.buildoutTearDown,
optionflags=doctest.NORMALIZE_WHITESPACE | doctest.ELLIPSIS,
checker=renormalizing.RENormalizing([
zc.buildout.testing.normalize_path,
zc.buildout.testing.normalize_endings,
zc.buildout.testing.normalize_script,
zc.buildout.testing.normalize_egg_py,
zc.buildout.tests.normalize_bang,
zc.buildout.tests.normalize_S,
zc.buildout.testing.not_found,
zc.buildout.testing.python27_warning,
zc.buildout.testing.python27_warning_2,
zc.buildout.testing.easyinstall_deprecated,
(re.compile(r'[d-] zc.buildout(-\S+)?[.]egg(-link)?'),
'zc.buildout.egg'),
(re.compile(r'[d-] setuptools-[^-]+-'), 'setuptools-X-'),
(re.compile(r'eggs\\\\demo'), 'eggs/demo'),
(re.compile(r'[a-zA-Z]:\\\\foo\\\\bar'), '/foo/bar'),
])
),
] ]
if not WINDOWS: if not WINDOWS:
suites.append( suites.append(
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment