summaryrefslogtreecommitdiffstats
path: root/dhpython/build
diff options
context:
space:
mode:
Diffstat (limited to 'dhpython/build')
-rw-r--r--dhpython/build/__init__.py42
-rw-r--r--dhpython/build/base.py293
-rw-r--r--dhpython/build/plugin_autopkgtest.py38
-rw-r--r--dhpython/build/plugin_cmake.py71
-rw-r--r--dhpython/build/plugin_custom.py48
-rw-r--r--dhpython/build/plugin_distutils.py121
-rw-r--r--dhpython/build/plugin_flit.py170
-rw-r--r--dhpython/build/plugin_pyproject.py201
8 files changed, 984 insertions, 0 deletions
diff --git a/dhpython/build/__init__.py b/dhpython/build/__init__.py
new file mode 100644
index 0000000..d350ccb
--- /dev/null
+++ b/dhpython/build/__init__.py
@@ -0,0 +1,42 @@
+# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import logging
+from glob import glob1
+from os.path import dirname
+
+from dhpython.exceptions import RequiredCommandMissingException
+
+log = logging.getLogger('dhpython')
+
+plugins = {}
+for i in sorted(i[7:-3] for i in glob1(dirname(__file__), 'plugin_*.py')):
+ try:
+ module = __import__("dhpython.build.plugin_%s" % i, fromlist=[i])
+ module.BuildSystem.NAME = i
+ module.BuildSystem.is_usable()
+ plugins[i] = module.BuildSystem
+ except RequiredCommandMissingException as err:
+ log.debug("cannot initialize '%s' plugin: Missing command '%s'", i, err)
+ except Exception as err:
+ if log.level < logging.INFO:
+ log.debug("cannot initialize '%s' plugin", i, exc_info=True)
+ else:
+ log.debug("cannot initialize '%s' plugin: %s", i, err)
diff --git a/dhpython/build/base.py b/dhpython/build/base.py
new file mode 100644
index 0000000..427ef2e
--- /dev/null
+++ b/dhpython/build/base.py
@@ -0,0 +1,293 @@
+# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import logging
+from functools import wraps
+from glob import glob1
+from os import remove, walk
+from os.path import exists, isdir, join
+from subprocess import Popen, PIPE
+from shutil import rmtree, copyfile, copytree
+from dhpython.exceptions import RequiredCommandMissingException
+from dhpython.tools import execute
+try:
+ from shlex import quote
+except ImportError:
+ # shlex.quote is new in Python 3.3
+ def quote(s):
+ if not s:
+ return "''"
+ return "'" + s.replace("'", "'\"'\"'") + "'"
+
+log = logging.getLogger('dhpython')
+
+
+def copy_test_files(dest='{build_dir}',
+ filelist='{home_dir}/testfiles_to_rm_before_install',
+ add_to_args=('test', 'tests')):
+
+ def _copy_test_files(func):
+
+ @wraps(func)
+ def __copy_test_files(self, context, args, *oargs, **kwargs):
+ files_to_copy = {'test', 'tests'}
+ # check debian/pybuild_pythonX.Y.testfiles
+ for tpl in ('_{i}{v}', '_{i}{m}', ''):
+ tpl = tpl.format(i=args['interpreter'].name,
+ v=args['version'],
+ m=args['version'].major)
+ fpath = join(args['dir'], 'debian/pybuild{}.testfiles'.format(tpl))
+ if exists(fpath):
+ with open(fpath, encoding='utf-8') as fp:
+ # overwrite files_to_copy if .testfiles file found
+ files_to_copy = [line.strip() for line in fp.readlines()
+ if not line.startswith('#')]
+ break
+
+ files_to_remove = set()
+ for name in files_to_copy:
+ src_dpath = join(args['dir'], name)
+ dst_dpath = join(dest.format(**args), name.rsplit('/', 1)[-1])
+ if exists(src_dpath):
+ if not exists(dst_dpath):
+ if isdir(src_dpath):
+ copytree(src_dpath, dst_dpath)
+ else:
+ copyfile(src_dpath, dst_dpath)
+ files_to_remove.add(dst_dpath + '\n')
+ if not args['args'] and 'PYBUILD_TEST_ARGS' not in context['ENV']\
+ and (self.cfg.test_pytest or self.cfg.test_nose) \
+ and name in add_to_args:
+ args['args'] = name
+ if files_to_remove and filelist:
+ with open(filelist.format(**args), 'a') as fp:
+ fp.writelines(files_to_remove)
+
+ return func(self, context, args, *oargs, **kwargs)
+ return __copy_test_files
+ return _copy_test_files
+
+
+class Base:
+ """Base class for build system plugins
+
+ :attr REQUIRED_COMMANDS: list of command checked by default in :meth:is_usable,
+ if one of them is missing, plugin cannot be used.
+ :type REQUIRED_COMMANDS: list of strings
+ :attr REQUIRED_FILES: list of files (or glob templates) required by given
+ build system
+ :attr OPTIONAL_FILES: dictionary of glob templates (key) and score (value)
+ used to detect if given plugin is the best one for the job
+ :type OPTIONAL_FILES: dict (key is a string, value is an int)
+ :attr SUPPORTED_INTERPRETERS: set of interpreter templates (with or without
+ {version}) supported by given plugin
+ """
+ DESCRIPTION = ''
+ REQUIRED_COMMANDS = []
+ REQUIRED_FILES = []
+ OPTIONAL_FILES = {}
+ SUPPORTED_INTERPRETERS = {'python', 'python3', 'python-dbg', 'python3-dbg',
+ 'python{version}', 'python{version}-dbg'}
+ # files and directories to remove during clean step (other than .pyc):
+ CLEAN_FILES = {'.pytest_cache', '.coverage'}
+
+ def __init__(self, cfg):
+ self.cfg = cfg
+
+ def __repr__(self):
+ return "BuildSystem(%s)" % self.NAME
+
+ @classmethod
+ def is_usable(cls):
+ for command in cls.REQUIRED_COMMANDS:
+ process = Popen(['which', command], stdout=PIPE, stderr=PIPE)
+ out, err = process.communicate()
+ if process.returncode != 0:
+ raise RequiredCommandMissingException(command)
+
+ def detect(self, context):
+ """Return certainty level that this plugin describes the right build system
+
+ This method is using cls.{REQUIRED,OPTIONAL}_FILES only by default,
+ please extend it in the plugin if more sofisticated methods can be used
+ for given build system.
+
+ :return: 0 <= certainty <= 100
+ :rtype: int
+ """
+ result = 0
+
+ required_files_num = 0
+ self.DETECTED_REQUIRED_FILES = {} # can be used in the plugin later
+ for tpl in self.REQUIRED_FILES:
+ found = False
+ for ftpl in tpl.split('|'):
+ res = glob1(context['dir'], ftpl)
+ if res:
+ found = True
+ self.DETECTED_REQUIRED_FILES.setdefault(tpl, []).extend(res)
+ if found:
+ required_files_num += 1
+ # add max 50 points depending on how many required files are available
+ if self.REQUIRED_FILES:
+ result += int(required_files_num / len(self.REQUIRED_FILES) * 50)
+
+ self.DETECTED_OPTIONAL_FILES = {}
+ for ftpl, score in self.OPTIONAL_FILES.items():
+ res = glob1(context['dir'], ftpl)
+ if res:
+ result += score
+ self.DETECTED_OPTIONAL_FILES.setdefault(ftpl, []).extend(res)
+ if result > 100:
+ return 100
+ return result
+
+ def clean(self, context, args):
+ if self.cfg.test_tox:
+ tox_dir = join(args['dir'], '.tox')
+ if isdir(tox_dir):
+ try:
+ rmtree(tox_dir)
+ except Exception:
+ log.debug('cannot remove %s', tox_dir)
+
+ for fn in self.CLEAN_FILES:
+ path = join(context['dir'], fn)
+ if isdir(path):
+ try:
+ rmtree(path)
+ except Exception:
+ log.debug('cannot remove %s', path)
+ elif exists(path):
+ try:
+ remove(path)
+ except Exception:
+ log.debug('cannot remove %s', path)
+
+ for root, dirs, file_names in walk(context['dir']):
+ for name in dirs:
+ if name == '__pycache__':
+ dpath = join(root, name)
+ log.debug('removing dir: %s', dpath)
+ try:
+ rmtree(dpath)
+ except Exception:
+ log.debug('cannot remove %s', dpath)
+ else:
+ dirs.remove(name)
+ for fn in file_names:
+ if fn.endswith(('.pyc', '.pyo')):
+ fpath = join(root, fn)
+ log.debug('removing: %s', fpath)
+ try:
+ remove(fpath)
+ except Exception:
+ log.debug('cannot remove %s', fpath)
+
+ def configure(self, context, args):
+ raise NotImplementedError("configure method not implemented in %s" % self.NAME)
+
+ def install(self, context, args):
+ raise NotImplementedError("install method not implemented in %s" % self.NAME)
+
+ def build(self, context, args):
+ raise NotImplementedError("build method not implemented in %s" % self.NAME)
+
+ @copy_test_files()
+ def test(self, context, args):
+ if self.cfg.test_nose2:
+ return 'cd {build_dir}; {interpreter} -m nose2 -v {args}'
+ elif self.cfg.test_nose:
+ return 'cd {build_dir}; {interpreter} -m nose -v {args}'
+ elif self.cfg.test_pytest:
+ return 'cd {build_dir}; {interpreter} -m pytest {args}'
+ elif self.cfg.test_tox:
+ # tox will call pip to install the module. Let it install the
+ # module inside the virtualenv
+ pydistutils_cfg = join(args['home_dir'], '.pydistutils.cfg')
+ if exists(pydistutils_cfg):
+ remove(pydistutils_cfg)
+ return 'cd {build_dir}; tox -c {dir}/tox.ini --sitepackages -e py{version.major}{version.minor} {args}'
+ elif self.cfg.test_custom:
+ return 'cd {build_dir}; {args}'
+ elif args['version'] == '2.7' or args['version'] >> '3.1' or args['interpreter'] == 'pypy':
+ return 'cd {build_dir}; {interpreter} -m unittest discover -v {args}'
+
+ def execute(self, context, args, command, log_file=None):
+ if log_file is False and self.cfg.really_quiet:
+ log_file = None
+ command = command.format(**args)
+ env = dict(context['ENV'])
+ if 'ENV' in args:
+ env.update(args['ENV'])
+ log.info(command)
+ return execute(command, context['dir'], env, log_file)
+
+ def print_args(self, context, args):
+ cfg = self.cfg
+ if len(cfg.print_args) == 1 and len(cfg.interpreter) == 1 and '{version}' not in cfg.interpreter[0]:
+ i = cfg.print_args[0]
+ if '{' in i:
+ print(i.format(**args))
+ else:
+ print(args.get(i, ''))
+ else:
+ for i in cfg.print_args:
+ if '{' in i:
+ print(i.format(**args))
+ else:
+ print('{} {}: {}'.format(args['interpreter'], i, args.get(i, '')))
+
+
+def shell_command(func):
+
+ @wraps(func)
+ def wrapped_func(self, context, args, *oargs, **kwargs):
+ command = kwargs.pop('command', None)
+ if not command:
+ command = func(self, context, args, *oargs, **kwargs)
+ if isinstance(command, int): # final result
+ return command
+ if not command:
+ log.warn('missing command '
+ '(plugin=%s, method=%s, interpreter=%s, version=%s)',
+ self.NAME, func.__name__,
+ args.get('interpreter'), args.get('version'))
+ return command
+
+ if self.cfg.quiet:
+ log_file = join(args['home_dir'], '{}_cmd.log'.format(func.__name__))
+ else:
+ log_file = False
+
+ quoted_args = dict((k, quote(v)) if k in ('dir', 'destdir')
+ or k.endswith('_dir') else (k, v)
+ for k, v in args.items())
+ command = command.format(**quoted_args)
+
+ output = self.execute(context, args, command, log_file)
+ if output['returncode'] != 0:
+ msg = 'exit code={}: {}'.format(output['returncode'], command)
+ if log_file:
+ msg += '\nfull command log is available in {}'.format(log_file)
+ raise Exception(msg)
+ return True
+
+ return wrapped_func
diff --git a/dhpython/build/plugin_autopkgtest.py b/dhpython/build/plugin_autopkgtest.py
new file mode 100644
index 0000000..b9a76e7
--- /dev/null
+++ b/dhpython/build/plugin_autopkgtest.py
@@ -0,0 +1,38 @@
+# vim: et ts=4 sw=4
+# Copyright © 2021 Antonio Terceiro <terceiro@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from dhpython.build.base import Base, shell_command
+
+
+class BuildSystem(Base):
+ """
+ autopkgtest test runner. All the methods that are not related to
+ testing (configure, build, clean etc) are not implemented by this class, on
+ purpose.
+ """
+
+ NAME = "autopkgtest"
+ DESCRIPTION = 'autopkgtest test runner'
+ SUPPORTED_INTERPRETERS = {'python3', 'python{version}'}
+
+ @shell_command
+ def test(self, context, args):
+ return super().test(context, args)
diff --git a/dhpython/build/plugin_cmake.py b/dhpython/build/plugin_cmake.py
new file mode 100644
index 0000000..7cf52e9
--- /dev/null
+++ b/dhpython/build/plugin_cmake.py
@@ -0,0 +1,71 @@
+# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from dhpython.build.base import Base, shell_command, copy_test_files
+
+
+class BuildSystem(Base):
+ DESCRIPTION = 'CMake build system (using dh_auto_* commands)'
+ REQUIRED_COMMANDS = ['cmake']
+ REQUIRED_FILES = ['CMakeLists.txt']
+ OPTIONAL_FILES = {'cmake_uninstall.cmake': 10, 'CMakeCache.txt': 10}
+
+ @shell_command
+ def clean(self, context, args):
+ super(BuildSystem, self).clean(context, args)
+ return 'dh_auto_clean --buildsystem=cmake'
+
+ @shell_command
+ def configure(self, context, args):
+ return ('dh_auto_configure --buildsystem=cmake'
+ ' --builddirectory={build_dir} --'
+ # FindPythonInterp:
+ ' -DPYTHON_EXECUTABLE:FILEPATH=/usr/bin/{interpreter}'
+ ' -DPYTHON_LIBRARY:FILEPATH={interpreter.library_file}'
+ ' -DPYTHON_INCLUDE_DIR:PATH={interpreter.include_dir}'
+ # FindPython:
+ ' -DPython_EXECUTABLE=/usr/bin/{interpreter}'
+ ' -DPython_LIBRARY={interpreter.library_file}'
+ ' -DPython_INCLUDE_DIR={interpreter.include_dir}'
+ # FindPython3:
+ ' -DPython3_EXECUTABLE=/usr/bin/{interpreter}'
+ ' -DPython3_LIBRARY={interpreter.library_file}'
+ ' -DPython3_INCLUDE_DIR={interpreter.include_dir}'
+ ' {args}')
+
+ @shell_command
+ def build(self, context, args):
+ return ('dh_auto_build --buildsystem=cmake'
+ ' --builddirectory={build_dir}'
+ ' -- {args}')
+
+ @shell_command
+ def install(self, context, args):
+ return ('dh_auto_install --buildsystem=cmake'
+ ' --builddirectory={build_dir}'
+ ' --destdir={destdir}'
+ ' -- {args}')
+
+ @shell_command
+ @copy_test_files()
+ def test(self, context, args):
+ return ('dh_auto_test --buildsystem=cmake'
+ ' --builddirectory={build_dir}'
+ ' -- {args}')
diff --git a/dhpython/build/plugin_custom.py b/dhpython/build/plugin_custom.py
new file mode 100644
index 0000000..8b317f2
--- /dev/null
+++ b/dhpython/build/plugin_custom.py
@@ -0,0 +1,48 @@
+# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from dhpython.build.base import Base, shell_command, copy_test_files
+
+
+class BuildSystem(Base):
+ DESCRIPTION = 'use --*-args options to configure this system'
+ SUPPORTED_INTERPRETERS = True # all interpreters
+
+ @shell_command
+ def clean(self, context, args):
+ super(BuildSystem, self).clean(context, args)
+ return args['args']
+
+ @shell_command
+ def configure(self, context, args):
+ return args['args']
+
+ @shell_command
+ def build(self, context, args):
+ return args['args']
+
+ @shell_command
+ def install(self, context, args):
+ return args['args']
+
+ @shell_command
+ @copy_test_files()
+ def test(self, context, args):
+ return args['args'] or super(BuildSystem, self).test(context, args)
diff --git a/dhpython/build/plugin_distutils.py b/dhpython/build/plugin_distutils.py
new file mode 100644
index 0000000..342ec6f
--- /dev/null
+++ b/dhpython/build/plugin_distutils.py
@@ -0,0 +1,121 @@
+# Copyright © 2012-2013 Piotr Ożarowski <piotr@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import logging
+from glob import glob1
+from os import remove
+from os.path import exists, isdir, join
+from shutil import rmtree
+from dhpython.build.base import Base, shell_command, copy_test_files
+
+log = logging.getLogger('dhpython')
+_setup_tpl = 'setup.py|setup-3.py'
+
+
+def create_pydistutils_cfg(func):
+ """distutils doesn't have sane command-line API - this decorator creates
+ .pydistutils.cfg file to workaround it
+
+ hint: if you think this is plain stupid, please don't read
+ distutils/setuptools/distribute sources
+ """
+
+ def wrapped_func(self, context, args, *oargs, **kwargs):
+ fpath = join(args['home_dir'], '.pydistutils.cfg')
+ if not exists(fpath):
+ with open(fpath, 'w', encoding='utf-8') as fp:
+ lines = ['[clean]\n',
+ 'all=1\n',
+ '[build]\n',
+ 'build_lib={}\n'.format(args['build_dir']),
+ '[install]\n',
+ 'force=1\n',
+ 'install_layout=deb\n',
+ 'install_scripts=$base/bin\n',
+ 'install_lib={}\n'.format(args['install_dir']),
+ 'prefix=/usr\n']
+ log.debug('pydistutils config file:\n%s', ''.join(lines))
+ fp.writelines(lines)
+ context['ENV']['HOME'] = args['home_dir']
+ return func(self, context, args, *oargs, **kwargs)
+
+ wrapped_func.__name__ = func.__name__
+ return wrapped_func
+
+
+class BuildSystem(Base):
+ DESCRIPTION = 'Distutils build system'
+ SUPPORTED_INTERPRETERS = {'python', 'python3', 'python{version}',
+ 'python-dbg', 'python3-dbg', 'python{version}-dbg',
+ 'pypy'}
+ REQUIRED_FILES = [_setup_tpl]
+ OPTIONAL_FILES = {'setup.cfg': 1,
+ 'requirements.txt': 1,
+ 'PKG-INFO': 10,
+ '*.egg-info': 10}
+ CLEAN_FILES = Base.CLEAN_FILES | {'build'}
+
+ def detect(self, context):
+ result = super(BuildSystem, self).detect(context)
+ if _setup_tpl in self.DETECTED_REQUIRED_FILES:
+ context['args']['setup_py'] = self.DETECTED_REQUIRED_FILES[_setup_tpl][0]
+ else:
+ context['args']['setup_py'] = 'setup.py'
+ return result
+
+ @shell_command
+ @create_pydistutils_cfg
+ def clean(self, context, args):
+ super(BuildSystem, self).clean(context, args)
+ if exists(args['interpreter'].binary()):
+ return '{interpreter} {setup_py} clean {args}'
+ return 0 # no need to invoke anything
+
+ @shell_command
+ @create_pydistutils_cfg
+ def configure(self, context, args):
+ return '{interpreter} {setup_py} config {args}'
+
+ @shell_command
+ @create_pydistutils_cfg
+ def build(self, context, args):
+ return '{interpreter.binary_dv} {setup_py} build {args}'
+
+ @shell_command
+ @create_pydistutils_cfg
+ def install(self, context, args):
+ # remove egg-info dirs from build_dir
+ for fname in glob1(args['build_dir'], '*.egg-info'):
+ fpath = join(args['build_dir'], fname)
+ rmtree(fpath) if isdir(fpath) else remove(fpath)
+
+ return '{interpreter.binary_dv} {setup_py} install --root {destdir} {args}'
+
+ @shell_command
+ @create_pydistutils_cfg
+ @copy_test_files()
+ def test(self, context, args):
+ if not self.cfg.custom_tests:
+ fpath = join(args['dir'], args['setup_py'])
+ with open(fpath, 'rb') as fp:
+ if fp.read().find(b'test_suite') > 0:
+ # TODO: is that enough to detect if test target is available?
+ return '{interpreter} {setup_py} test {args}'
+ return super(BuildSystem, self).test(context, args)
diff --git a/dhpython/build/plugin_flit.py b/dhpython/build/plugin_flit.py
new file mode 100644
index 0000000..004d657
--- /dev/null
+++ b/dhpython/build/plugin_flit.py
@@ -0,0 +1,170 @@
+# Copyright © 2012-2020 Piotr Ożarowski <piotr@debian.org>
+# © 2020 Scott Kitterman <scott@kitterman.com>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from fnmatch import fnmatch
+from pathlib import Path
+import copy
+import csv
+import logging
+import os
+import os.path as osp
+import shutil
+import sysconfig
+try:
+ import tomli
+except ModuleNotFoundError:
+ # Plugin still works, only needed for autodetection
+ pass
+try:
+ from flit.install import Installer
+except ImportError:
+ Installer = object
+
+from dhpython.build.base import Base, shell_command
+
+log = logging.getLogger('dhpython')
+
+
+class DebianInstaller(Installer):
+ def install_directly(self, destdir, installdir):
+ """Install a module/package into package directory, and create its
+ scripts.
+ """
+ if installdir[:1] == os.sep:
+ installdir = installdir[1:]
+
+ vars_ = copy.copy(sysconfig.get_config_vars())
+ vars_['base'] = destdir + vars_['base']
+ try:
+ dirs = sysconfig.get_paths(scheme='deb_system', vars=vars_)
+ except KeyError:
+ # Debian hasn't patched sysconfig schemes until 3.10
+ # TODO: Introduce a version check once sysconfig is patched.
+ dirs = sysconfig.get_paths(scheme='posix_prefix', vars=vars_)
+
+ dirs['purelib'] = dirs['platlib'] = osp.join(destdir, installdir)
+ os.makedirs(dirs['purelib'], exist_ok=True)
+ os.makedirs(dirs['scripts'], exist_ok=True)
+
+ dst = osp.join(dirs['purelib'], osp.basename(self.module.path))
+ if osp.lexists(dst):
+ if osp.isdir(dst) and not osp.islink(dst):
+ shutil.rmtree(dst)
+ else:
+ os.unlink(dst)
+
+ src = str(self.module.path)
+ if self.module.is_package:
+ log.info("Installing package %s -> %s", src, dst)
+ shutil.copytree(src, dst)
+ self._record_installed_directory(dst)
+ else:
+ log.info("Installing file %s -> %s", src, dst)
+ shutil.copy2(src, dst)
+ self.installed_files.append(dst)
+
+ scripts = self.ini_info.entrypoints.get('console_scripts', {})
+ if scripts:
+ log.info("Installing scripts to %s", dirs['scripts'])
+ self.install_scripts(scripts, dirs['scripts'])
+
+ log.info("Writing dist-info %s", dirs['purelib'])
+ self.write_dist_info(dirs['purelib'])
+ # Remove direct_url.json - contents are not useful or reproduceable
+ for path in Path(dirs['purelib']).glob("*.dist-info/direct_url.json"):
+ path.unlink()
+ # Remove build path from RECORD files
+ for path in Path(dirs['purelib']).glob("*.dist-info/RECORD"):
+ with open(path) as f:
+ reader = csv.reader(f)
+ records = list(reader)
+ with open(path, 'w') as f:
+ writer = csv.writer(f)
+ for path, hash_, size in records:
+ path = path.replace(destdir, '')
+ if fnmatch(path, "*.dist-info/direct_url.json"):
+ continue
+ writer.writerow([path, hash_, size])
+
+
+class BuildSystem(Base):
+ DESCRIPTION = 'Flit build system'
+ SUPPORTED_INTERPRETERS = {'python3', 'python{version}'}
+ REQUIRED_FILES = ['pyproject.toml']
+ OPTIONAL_FILES = {}
+ CLEAN_FILES = Base.CLEAN_FILES | {'build'}
+
+ def detect(self, context):
+ """Return certainty level that this plugin describes the right build
+ system
+
+ This method uses cls.{REQUIRED}_FILES (pyroject.toml) as well as
+ checking to see if build-backend is set to flit_core.buildapi.
+
+ Score is 85 if both are present (to allow manually setting distutils to
+ score higher if set).
+
+ :return: 0 <= certainty <= 100
+ :rtype: int
+ """
+ if Installer is object:
+ return 0
+
+ result = super().detect(context)
+ if result > 100:
+ return 100
+ return result
+
+ def clean(self, context, args):
+ super().clean(context, args)
+ if osp.exists(args['interpreter'].binary()):
+ log.debug("removing '%s' (and everything under it)",
+ args['build_dir'])
+ osp.isdir(args['build_dir']) and shutil.rmtree(args['build_dir'])
+ return 0 # no need to invoke anything
+
+ def configure(self, context, args):
+ # Flit does not support binary extensions
+ return 0 # Not needed for flit
+
+ def build(self, context, args):
+ log.warning("The pybuild flit plugin is deprecated, "
+ "please use the pyproject plugin instead.")
+ my_dir = Path(args['dir'])
+ install_kwargs = {'user': False, 'symlink': False, 'deps': 'none'}
+ DebianInstaller.from_ini_path(my_dir / 'pyproject.toml',
+ **install_kwargs).install_directly(
+ args['build_dir'], '')
+ # These get byte compiled too, although it's not logged.
+ return 0 # Not needed for flit
+
+ def install(self, context, args):
+ my_dir = Path(args['dir'])
+ install_kwargs = {'user': False, 'symlink': False, 'deps': 'none'}
+ DebianInstaller.from_ini_path(my_dir / 'pyproject.toml',
+ **install_kwargs).install_directly(
+ args['destdir'],
+ args['install_dir'])
+ return 0 # Not needed for flit'
+
+ @shell_command
+ def test(self, context, args):
+ return super().test(context, args)
diff --git a/dhpython/build/plugin_pyproject.py b/dhpython/build/plugin_pyproject.py
new file mode 100644
index 0000000..b24aa9a
--- /dev/null
+++ b/dhpython/build/plugin_pyproject.py
@@ -0,0 +1,201 @@
+# Copyright © 2012-2020 Piotr Ożarowski <piotr@debian.org>
+# © 2020 Scott Kitterman <scott@kitterman.com>
+# © 2021 Stuart Prescott <stuart@debian.org>
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+from pathlib import Path
+import logging
+import os.path as osp
+import shutil
+import sysconfig
+try:
+ import tomli
+except ModuleNotFoundError:
+ # Plugin still works, only needed for autodetection
+ pass
+try:
+ from installer import install
+ from installer.destinations import SchemeDictionaryDestination
+ from installer.sources import WheelFile
+except ModuleNotFoundError:
+ SchemeDictionaryDestination = WheelFile = install = None
+
+from dhpython.build.base import Base, shell_command
+
+log = logging.getLogger('dhpython')
+
+
+class BuildSystem(Base):
+ DESCRIPTION = 'Generic PEP517 build system'
+ SUPPORTED_INTERPRETERS = {'python3', 'python{version}'}
+ REQUIRED_FILES = ['pyproject.toml']
+ OPTIONAL_FILES = {}
+ CLEAN_FILES = Base.CLEAN_FILES | {'build'}
+
+ def detect(self, context):
+ """Return certainty level that this plugin describes the right build
+ system
+
+ This method uses cls.{REQUIRED}_FILES (pyroject.toml) only; any
+ other PEP517 compliant builder (such as the flit) builder should
+ indicate higher specificity than this plugin.
+
+ :return: 0 <= certainty <= 100
+ :rtype: int
+ """
+ result = super().detect(context)
+ # Temporarily reduce the threshold while we're in beta
+ result -= 20
+
+ try:
+ with open('pyproject.toml', 'rb') as f:
+ pyproject = tomli.load(f)
+ if pyproject.get('build-system', {}).get('build-backend'):
+ result += 10
+ else:
+ # Not a PEP517 built package
+ result = 0
+ except NameError:
+ # No toml, no autdetection
+ result = 0
+ except FileNotFoundError:
+ # Not a PEP517 package
+ result = 0
+ if result > 100:
+ return 100
+ return result
+
+ def clean(self, context, args):
+ super().clean(context, args)
+ if osp.exists(args['interpreter'].binary()):
+ log.debug("removing '%s' (and everything under it)",
+ args['build_dir'])
+ osp.isdir(args['build_dir']) and shutil.rmtree(args['build_dir'])
+ return 0 # no need to invoke anything
+
+ def configure(self, context, args):
+ if install is None:
+ raise Exception("PEP517 plugin dependencies are not available. "
+ "Please Build-Depend on pybuild-plugin-pyproject.")
+ # No separate configure step
+ return 0
+
+ def build(self, context, args):
+ self.build_step1(context, args)
+ self.build_step2(context, args)
+
+ @shell_command
+ def build_step1(self, context, args):
+ """ build a wheel using the PEP517 builder defined by upstream """
+ log.info('Building wheel for %s with "build" module',
+ args['interpreter'])
+ context['ENV']['FLIT_NO_NETWORK'] = '1'
+ context['ENV']['HOME'] = args['home_dir']
+ return ('{interpreter} -m build '
+ '--skip-dependency-check --no-isolation --wheel '
+ '--outdir ' + args['home_dir'] +
+ ' {args}'
+ )
+
+ def build_step2(self, context, args):
+ """ unpack the wheel into pybuild's normal """
+ log.info('Unpacking wheel built for %s with "installer" module',
+ args['interpreter'])
+ extras = {}
+ for extra in ('scripts', 'data'):
+ path = Path(args["home_dir"]) / extra
+ if osp.exists(path):
+ log.warning(f'{extra.title()} directory already exists, '
+ 'skipping unpack. '
+ 'Is the Python package being built twice?')
+ return
+ extras[extra] = path
+ destination = SchemeDictionaryDestination(
+ {
+ 'platlib': args['build_dir'],
+ 'purelib': args['build_dir'],
+ 'scripts': extras['scripts'],
+ 'data': extras['data'],
+ },
+ interpreter=args['interpreter'].binary_dv,
+ script_kind='posix',
+ )
+
+ # FIXME this next step will unpack every single wheel file it finds
+ # which is probably ok since each wheel is built in a separate
+ # directory; but perhaps it should only accept the correctly named
+ # wheels that match the current interpreter?
+ # python-packaging has relevant utilities in
+ # - packaging/utils.py::parse_wheel_filename
+ # - packaging/tags.py (although it is current-interpreter-centric)
+ wheels = Path(args['home_dir']).glob('*.whl')
+ for wheel in wheels:
+ if wheel.name.startswith('UNKNOWN'):
+ raise Exception(f'UNKNOWN wheel found: {wheel.name}. Does '
+ 'pyproject.toml specify a build-backend?')
+ with WheelFile.open(wheel) as source:
+ install(
+ source=source,
+ destination=destination,
+ additional_metadata={},
+ )
+
+ def install(self, context, args):
+ log.info('Copying package built for %s to destdir',
+ args['interpreter'])
+ try:
+ paths = sysconfig.get_paths(scheme='deb_system')
+ except KeyError:
+ # Debian hasn't patched sysconfig schemes until 3.10
+ # TODO: Introduce a version check once sysconfig is patched.
+ paths = sysconfig.get_paths(scheme='posix_prefix')
+
+ # start by copying the data and scripts
+ for extra in ('data', 'scripts'):
+ src_dir = Path(args['home_dir']) / extra
+ if not src_dir.exists():
+ continue
+ target_dir = args['destdir'] + paths[extra]
+ log.debug('Copying %s directory contents from %s -> %s',
+ extra, src_dir, target_dir)
+ shutil.copytree(
+ src_dir,
+ target_dir,
+ dirs_exist_ok=True,
+ )
+
+ # then copy the modules
+ module_dir = args['build_dir']
+ target_dir = args['destdir'] + args['install_dir']
+ log.debug('Copying module contents from %s -> %s',
+ module_dir, target_dir)
+ shutil.copytree(
+ module_dir,
+ target_dir,
+ dirs_exist_ok=True,
+ )
+
+ @shell_command
+ def test(self, context, args):
+ scripts = Path(args["home_dir"]) / 'scripts'
+ if scripts.exists():
+ context['ENV']['PATH'] = f"{scripts}:{context['ENV']['PATH']}"
+ context['ENV']['HOME'] = args['home_dir']
+ return super().test(context, args)