summaryrefslogtreecommitdiffstats
path: root/dhpython
diff options
context:
space:
mode:
Diffstat (limited to 'dhpython')
-rw-r--r--dhpython/__init__.py113
-rwxr-xr-xdhpython/_defaults.py99
-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
-rw-r--r--dhpython/debhelper.py327
-rw-r--r--dhpython/depends.py281
-rw-r--r--dhpython/exceptions.py23
-rw-r--r--dhpython/fs.py587
-rw-r--r--dhpython/interpreter.py576
-rw-r--r--dhpython/markers.py70
-rw-r--r--dhpython/option.py30
-rw-r--r--dhpython/pydist.py692
-rw-r--r--dhpython/tools.py340
-rw-r--r--dhpython/version.py457
20 files changed, 4579 insertions, 0 deletions
diff --git a/dhpython/__init__.py b/dhpython/__init__.py
new file mode 100644
index 0000000..338eb17
--- /dev/null
+++ b/dhpython/__init__.py
@@ -0,0 +1,113 @@
+# Copyright © 2010-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 re
+
+PKG_PREFIX_MAP = {'cpython2': 'python',
+ 'cpython3': 'python3',
+ 'pypy': 'pypy'}
+
+# minimum version required for compile/clean scripts:
+MINPYCDEP = {'cpython2': 'python2:any',
+ 'cpython3': 'python3:any',
+ 'pypy': 'pypy'}
+
+PUBLIC_DIR_RE = {
+ 'cpython2': re.compile(r'.*?/usr/lib/python(2\.\d)(?:/|$)'),
+ 'cpython3': re.compile(r'.*?/usr/lib/python(3(?:\.\d+)?)(?:/|$)'),
+ 'pypy': re.compile(r'.*?/usr/lib/pypy(?:/|$)')}
+
+INTERPRETER_DIR_TPLS = {
+ 'cpython2': r'.*/python2\.\d/',
+ 'cpython3': r'.*/python3(?:\.\d+)?/',
+ 'pypy': r'.*/pypy/'}
+
+MULTIARCH_DIR_TPL = re.compile(
+ '.*/([a-z][^/-]+-(?:linux|kfreebsd|gnu)(?:-[^/-]+)?)(?:/.*|$)')
+
+# Interpreter site-directories
+OLD_SITE_DIRS = {
+ 'cpython2': [
+ '/usr/local/lib/python{}/site-packages',
+ '/usr/local/lib/python{}/dist-packages',
+ '/var/lib/python-support/python{}',
+ '/usr/lib/pymodules/python{}',
+ lambda version: '/usr/lib/python{}/site-packages'.format(version)
+ if version >= '2.6' else None],
+ 'cpython3': [
+ '/usr/local/lib/python{}/site-packages',
+ '/usr/local/lib/python{}/dist-packages',
+ '/usr/lib/python{}/site-packages',
+ '/usr/lib/python{}/dist-packages',
+ '/var/lib/python-support/python{}',
+ '/usr/lib/pymodules/python{}'],
+ 'pypy': [
+ '/usr/local/lib/pypy/site-packages',
+ '/usr/local/lib/pypy/dist-packages',
+ '/usr/lib/pypy/site-packages']}
+
+# PyDist related
+PYDIST_DIRS = {
+ 'cpython2': '/usr/share/python/dist/',
+ 'cpython3': '/usr/share/python3/dist/',
+ 'pypy': '/usr/share/pypy/dist/'}
+
+PYDIST_OVERRIDES_FNAMES = {
+ 'cpython2': 'debian/pydist-overrides',
+ 'cpython3': 'debian/py3dist-overrides',
+ 'pypy': 'debian/pypydist-overrides'}
+
+PYDIST_DPKG_SEARCH_TPLS = {
+ # implementation: (dpkg -S query, regex filter)
+ 'cpython2': ('*/{}-?*.*-info',
+ r'/(python2\..|pyshared)/.*.(egg|dist)-info$'),
+ 'cpython3': ('*python3/*/{}-?*.*-info', r'.(egg|dist)-info$'),
+ 'pypy': ('*/pypy/dist-packages/{}-?*.*-info', r'.(egg|dist)-info$'),
+}
+
+# DebHelper related
+DEPENDS_SUBSTVARS = {
+ 'cpython2': '${python:Depends}',
+ 'cpython3': '${python3:Depends}',
+ 'pypy': '${pypy:Depends}',
+}
+PKG_NAME_TPLS = {
+ 'cpython2': ('python-', 'python2.'),
+ 'cpython3': ('python3-', 'python3.'),
+ 'pypy': ('pypy-',)
+}
+RT_LOCATIONS = {
+ 'cpython2': '/usr/share/python/runtime.d/',
+ 'cpython3': '/usr/share/python3/runtime.d/',
+ 'pypy': '/usr/share/pypy/runtime.d/',
+}
+RT_TPLS = {
+ 'cpython2': '''
+if [ "$1" = rtupdate ]; then
+\tpyclean {pkg_arg} {dname}
+\tpycompile {pkg_arg} {args} {dname}
+fi''',
+ 'cpython3': '''
+if [ "$1" = rtupdate ]; then
+\tpy3clean {pkg_arg} {dname}
+\tpy3compile {pkg_arg} {args} {dname}
+fi''',
+ 'pypy': ''
+}
diff --git a/dhpython/_defaults.py b/dhpython/_defaults.py
new file mode 100755
index 0000000..cf7537b
--- /dev/null
+++ b/dhpython/_defaults.py
@@ -0,0 +1,99 @@
+#! /usr/bin/python3
+# Copyright © 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 configparser import ConfigParser
+from os import environ
+from os.path import exists
+from subprocess import Popen, PIPE
+
+SUPPORTED = {
+ 'cpython2': [(2, 7)],
+ 'cpython3': [(3, 8)],
+ 'pypy': [(4, 0)]}
+DEFAULT = {
+ 'cpython2': (2, 7),
+ 'cpython3': (3, 8),
+ 'pypy': (4, 0)}
+
+log = logging.getLogger('dhpython')
+
+
+def cpython_versions(major):
+ result = [None, None]
+ ver = '' if major == 2 else '3'
+ supported = environ.get("DEBPYTHON{}_SUPPORTED".format(ver))
+ default = environ.get("DEBPYTHON{}_DEFAULT".format(ver))
+ if not supported or not default:
+ config = ConfigParser()
+ config.read("/usr/share/python{}/debian_defaults".format(ver))
+ if not default:
+ default = config.get('DEFAULT', 'default-version', fallback='')[6:]
+ if not supported:
+ supported = config.get('DEFAULT', 'supported-versions', fallback='')\
+ .replace('python', '')
+ if default:
+ try:
+ result[0] = tuple(int(i) for i in default.split('.'))
+ except Exception as err:
+ log.warn('invalid debian_defaults file: %s', err)
+ if supported:
+ try:
+ result[1] = tuple(tuple(int(j) for j in i.strip().split('.'))
+ for i in supported.split(','))
+ except Exception as err:
+ log.warn('invalid debian_defaults file: %s', err)
+ return result
+
+
+def from_file(fpath):
+ if not exists(fpath):
+ raise ValueError("missing interpreter: %s" % fpath)
+ command = "{} --version".format(fpath)
+ with Popen(command, shell=True, stdout=PIPE) as process:
+ stdout, stderr = process.communicate()
+ stdout = str(stdout, 'utf-8')
+
+ print(stdout)
+
+
+cpython2 = cpython_versions(2)
+cpython3 = cpython_versions(3)
+if cpython2[0]:
+ DEFAULT['cpython2'] = cpython2[0]
+if cpython3[0]:
+ DEFAULT['cpython3'] = cpython3[0]
+if cpython2[1]:
+ SUPPORTED['cpython2'] = cpython2[1]
+if cpython3[1]:
+ SUPPORTED['cpython3'] = cpython3[1]
+#from_file('/usr/bin/pypy')
+
+
+if __name__ == '__main__':
+ from sys import argv, stderr
+ if len(argv) != 3:
+ print('invalid number of arguments', file=stderr)
+ exit(1)
+ if argv[1] == 'default':
+ print('.'.join(str(i) for i in DEFAULT[argv[2]]))
+ elif argv[1] == 'supported':
+ print(','.join(('.'.join(str(i) for i in v) for v in SUPPORTED[argv[2]])))
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)
diff --git a/dhpython/debhelper.py b/dhpython/debhelper.py
new file mode 100644
index 0000000..f497a60
--- /dev/null
+++ b/dhpython/debhelper.py
@@ -0,0 +1,327 @@
+# Copyright © 2010-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 errno
+import logging
+import re
+from os import makedirs, chmod, environ
+from os.path import basename, exists, join, dirname
+from sys import argv
+from dhpython import DEPENDS_SUBSTVARS, PKG_NAME_TPLS, RT_LOCATIONS, RT_TPLS
+
+log = logging.getLogger('dhpython')
+parse_dep = re.compile('''[,\s]*
+ (?P<name>[^ :]+)(?::any)?
+ \s*
+ \(?(?P<version>([>=<]{2,}|=)\s*[^\)]+)?\)?
+ \s*
+ (?:\[(?P<arch>[^\]]+)\])?
+ ''', re.VERBOSE).match
+
+
+def build_options(**options):
+ """Build an Options object from kw options"""
+ default_options = {
+ 'arch': None,
+ 'package': [],
+ 'no_package': [],
+ 'write_log': False,
+ 'remaining_packages': False,
+ }
+ built_options = default_options
+ built_options.update(options)
+ return type('Options', (object,), built_options)
+
+
+class DebHelper:
+ """Reinvents the wheel / some dh functionality (Perl is ugly ;-P)"""
+
+ def __init__(self, options, impl='cpython3'):
+ self.options = options
+ self.packages = {}
+ self.build_depends = {}
+ self.python_version = None
+ # Note that each DebHelper instance supports ONE interpreter type only
+ # it's not possible to mix cpython2, cpython3 and pypy here
+ self.impl = impl
+ self.command = {
+ 'cpython2': 'dh_python2',
+ 'cpython3': 'dh_python3',
+ 'pypy': 'dh_pypy',
+ }[impl]
+ skip_tpl = set()
+ for name, tpls in PKG_NAME_TPLS.items():
+ if name != impl:
+ skip_tpl.update(tpls)
+ skip_tpl = tuple(skip_tpl)
+ substvar = DEPENDS_SUBSTVARS[impl]
+
+ pkgs = options.package
+ skip_pkgs = options.no_package
+
+ try:
+ with open('debian/control', 'r', encoding='utf-8') as fp:
+ paragraphs = [{}]
+ field = None
+ for lineno, line in enumerate(fp, 1):
+ if line.startswith('#'):
+ continue
+ if not line.strip():
+ if paragraphs[-1]:
+ paragraphs.append({})
+ field = None
+ continue
+ if line[0].isspace(): # Continuation
+ paragraphs[-1][field] += line.rstrip()
+ continue
+ if not ':' in line:
+ raise Exception(
+ 'Unable to parse line %i in debian/control: %s'
+ % (lineno, line))
+ field, value = line.split(':', 1)
+ field = field.lower()
+ paragraphs[-1][field] = value.strip()
+ except IOError:
+ raise Exception('cannot find debian/control file')
+
+ # Trailing new lines?
+ if not paragraphs[-1]:
+ paragraphs.pop()
+
+ if len(paragraphs) < 2:
+ raise Exception('Unable to parse debian/control, found less than '
+ '2 paragraphs')
+
+ self.source_name = paragraphs[0]['source']
+ if self.impl == 'cpython3' and 'x-python3-version' in paragraphs[0]:
+ self.python_version = paragraphs[0]['x-python3-version']
+ if len(self.python_version.split(',')) > 2:
+ raise ValueError('too many arguments provided for '
+ 'X-Python3-Version: min and max only.')
+ elif self.impl == 'cpython2':
+ if 'x-python-version' in paragraphs[0]:
+ self.python_version = paragraphs[0]['x-python-version']
+ elif 'xs-python-version' in paragraphs[0]:
+ self.python_version = paragraphs[0]['xs-python-version']
+
+ build_depends = []
+ for field in ('build-depends', 'build-depends-indep',
+ 'build-depends-arch'):
+ if field in paragraphs[0]:
+ build_depends.append(paragraphs[0][field])
+ build_depends = ', '.join(build_depends)
+ for dep1 in build_depends.split(','):
+ for dep2 in dep1.split('|'):
+ details = parse_dep(dep2)
+ if details:
+ details = details.groupdict()
+ if details['arch']:
+ architectures = details['arch'].split()
+ else:
+ architectures = [None]
+ for arch in architectures:
+ self.build_depends.setdefault(
+ details['name'], {})[arch] = details['version']
+
+ for paragraph_no, paragraph in enumerate(paragraphs[1:], 2):
+ if 'package' not in paragraph:
+ raise Exception('Unable to parse debian/control, paragraph %i '
+ 'missing Package field' % paragraph_no)
+ binary_package = paragraph['package']
+ if skip_tpl and binary_package.startswith(skip_tpl):
+ log.debug('skipping package: %s', binary_package)
+ continue
+ if pkgs and binary_package not in pkgs:
+ continue
+ if skip_pkgs and binary_package in skip_pkgs:
+ continue
+ if (options.remaining_packages and
+ self.has_acted_on_package(binary_package)):
+ continue
+ pkg = {
+ 'substvars': {},
+ 'autoscripts': {},
+ 'rtupdates': [],
+ 'arch': paragraph['architecture'],
+ }
+ if (options.arch is False and pkg['arch'] != 'all' or
+ options.arch is True and pkg['arch'] == 'all'):
+ # TODO: check also if arch matches current architecture:
+ continue
+
+ if not binary_package.startswith(PKG_NAME_TPLS[impl]):
+ # package doesn't have common prefix (python-, python3-, pypy-)
+ # so lets check if Depends/Recommends contains the
+ # appropriate substvar
+ if (substvar not in paragraph.get('depends', '')
+ and substvar not in paragraph.get('recommends', '')):
+ log.debug('skipping package %s (missing %s in '
+ 'Depends/Recommends)',
+ binary_package, substvar)
+ continue
+ # Operate on binary_package
+ self.packages[binary_package] = pkg
+
+ fp.close()
+ log.debug('source=%s, binary packages=%s', self.source_name,
+ list(self.packages.keys()))
+
+ def has_acted_on_package(self, package):
+ try:
+ with open('debian/{}.debhelper.log'.format(package),
+ encoding='utf-8') as f:
+ for line in f:
+ if line.strip() == self.command:
+ return True
+ except IOError as e:
+ if e.errno != errno.ENOENT:
+ raise
+ return False
+
+ def addsubstvar(self, package, name, value):
+ """debhelper's addsubstvar"""
+ self.packages[package]['substvars'].setdefault(name, []).append(value)
+
+ def autoscript(self, package, when, template, args):
+ """debhelper's autoscript"""
+ self.packages[package]['autoscripts'].setdefault(when, {})\
+ .setdefault(template, []).append(args)
+
+ def add_rtupdate(self, package, value):
+ self.packages[package]['rtupdates'].append(value)
+
+ def save_autoscripts(self):
+ for package, settings in self.packages.items():
+ autoscripts = settings.get('autoscripts')
+ if not autoscripts:
+ continue
+
+ for when, templates in autoscripts.items():
+ fn = "debian/%s.%s.debhelper" % (package, when)
+ if exists(fn):
+ with open(fn, 'r', encoding='utf-8') as datafile:
+ data = datafile.read()
+ else:
+ data = ''
+
+ new_data = ''
+ for tpl_name, args in templates.items():
+ for i in args:
+ # try local one first (useful while testing dh_python3)
+ fpath = join(dirname(__file__), '..',
+ "autoscripts/%s" % tpl_name)
+ if not exists(fpath):
+ fpath = "/usr/share/debhelper/autoscripts/%s" % tpl_name
+ with open(fpath, 'r', encoding='utf-8') as tplfile:
+ tpl = tplfile.read()
+ if self.options.compile_all and args:
+ # TODO: should args be checked to contain dir name?
+ tpl = tpl.replace('-p #PACKAGE#', '')
+ elif settings['arch'] == 'all':
+ tpl = tpl.replace('#PACKAGE#', package)
+ else:
+ arch = environ['DEB_HOST_ARCH']
+ tpl = tpl.replace('#PACKAGE#', '%s:%s' % (package, arch))
+ tpl = tpl.replace('#ARGS#', i)
+ if tpl not in data and tpl not in new_data:
+ new_data += "\n%s" % tpl
+ if new_data:
+ data += '\n# Automatically added by {}'.format(basename(argv[0])) +\
+ '{}\n# End automatically added section\n'.format(new_data)
+ fp = open(fn, 'w', encoding='utf-8')
+ fp.write(data)
+ fp.close()
+
+ def save_substvars(self):
+ for package, settings in self.packages.items():
+ substvars = settings.get('substvars')
+ if not substvars:
+ continue
+ fn = "debian/%s.substvars" % package
+ if exists(fn):
+ with open(fn, 'r', encoding='utf-8') as datafile:
+ data = datafile.read()
+ else:
+ data = ''
+ for name, values in substvars.items():
+ p = data.find("%s=" % name)
+ if p > -1: # parse the line and remove it from data
+ e = data[p:].find('\n')
+ line = data[p + len("%s=" % name):
+ p + e if e > -1 else None]
+ items = [i.strip() for i in line.split(',') if i]
+ if e > -1 and data[p + e:].strip():
+ data = "%s\n%s" % (data[:p], data[p + e:])
+ else:
+ data = data[:p]
+ else:
+ items = []
+ for j in values:
+ if j not in items:
+ items.append(j)
+ if items:
+ if data:
+ data += '\n'
+ data += "%s=%s\n" % (name, ', '.join(items))
+ data = data.replace('\n\n', '\n')
+ if data:
+ fp = open(fn, 'w', encoding='utf-8')
+ fp.write(data)
+ fp.close()
+
+ def save_rtupdate(self):
+ for package, settings in self.packages.items():
+ pkg_arg = '' if self.options.compile_all else "-p %s" % package
+ values = settings.get('rtupdates')
+ if not values:
+ continue
+ d = 'debian/{}/{}'.format(package, RT_LOCATIONS[self.impl])
+ if not exists(d):
+ makedirs(d)
+ fn = "%s/%s.rtupdate" % (d, package)
+ if exists(fn):
+ data = open(fn, 'r', encoding='utf-8').read()
+ else:
+ data = "#! /bin/sh\nset -e"
+ for dname, args in values:
+ cmd = RT_TPLS[self.impl].format(pkg_arg=pkg_arg,
+ dname=dname,
+ args=args)
+ if cmd not in data:
+ data += "\n%s" % cmd
+ if data:
+ fp = open(fn, 'w', encoding='utf-8')
+ fp.write(data)
+ fp.close()
+ chmod(fn, 0o755)
+
+ def save_log(self):
+ if not self.options.write_log:
+ return
+ for package, settings in self.packages.items():
+ with open('debian/{}.debhelper.log'.format(package),
+ 'a', encoding='utf-8') as f:
+ f.write(self.command + '\n')
+
+ def save(self):
+ self.save_substvars()
+ self.save_autoscripts()
+ self.save_rtupdate()
+ self.save_log()
diff --git a/dhpython/depends.py b/dhpython/depends.py
new file mode 100644
index 0000000..e17951c
--- /dev/null
+++ b/dhpython/depends.py
@@ -0,0 +1,281 @@
+# Copyright © 2010-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 partial
+from os.path import exists, join
+from dhpython import PKG_PREFIX_MAP, MINPYCDEP
+from dhpython.pydist import parse_pydep, parse_requires_dist, guess_dependency
+from dhpython.version import default, supported, VersionRange
+
+log = logging.getLogger('dhpython')
+
+
+class Dependencies:
+ """Store relations (dependencies, etc.) between packages."""
+
+ def __init__(self, package, impl='cpython3', bdep=None):
+ self.impl = impl
+ self.package = package
+ bdep = self.bdep = bdep or {}
+ self.is_debug_package = dbgpkg = package.endswith('-dbg')
+
+ # TODO: move it to PyPy and CPython{2,3} classes
+ self.ipkg_vtpl = 'python%s-dbg' if dbgpkg else 'python%s'
+ if impl == 'cpython3':
+ self.ipkg_tpl = 'python3-dbg' if dbgpkg else 'python3'
+ elif impl == 'cpython2':
+ self.ipkg_tpl = 'python2-dbg' if dbgpkg else 'python2'
+ elif impl == 'pypy':
+ self.ipkg_tpl = 'pypy-dbg' if dbgpkg else 'pypy'
+ self.ipkg_vtpl = 'pypy%s-dbg' if dbgpkg else 'pypy%s'
+ if impl == 'pypy':
+ self.ipkg_tpl_ma = self.ipkg_tpl
+ self.ipkg_vtpl_ma = self.ipkg_vtpl
+ else:
+ self.ipkg_tpl_ma = self.ipkg_tpl + ':any'
+ self.ipkg_vtpl_ma = self.ipkg_vtpl + ':any'
+
+ self.python_dev_in_bd = 'python-dev' in bdep or\
+ 'python-all-dev' in bdep or\
+ 'python2-dev' in bdep or\
+ 'python2-all-dev' in bdep or\
+ 'python2.7-dev' in bdep or\
+ 'python3-dev' in bdep or\
+ 'python3-all-dev' in bdep
+
+ self.depends = set()
+ self.recommends = []
+ self.suggests = []
+ self.enhances = []
+ self.breaks = []
+ self.rtscripts = []
+
+ def export_to(self, dh):
+ """Fill in debhelper's substvars."""
+ prefix = PKG_PREFIX_MAP.get(self.impl, 'misc')
+ for i in sorted(self.depends):
+ dh.addsubstvar(self.package, '{}:Depends'.format(prefix), i)
+ for i in sorted(self.recommends):
+ dh.addsubstvar(self.package, '{}:Recommends'.format(prefix), i)
+ for i in sorted(self.suggests):
+ dh.addsubstvar(self.package, '{}:Suggests'.format(prefix), i)
+ for i in sorted(self.enhances):
+ dh.addsubstvar(self.package, '{}:Enhances'.format(prefix), i)
+ for i in sorted(self.breaks):
+ dh.addsubstvar(self.package, '{}:Breaks'.format(prefix), i)
+ for i in sorted(self.rtscripts):
+ dh.add_rtupdate(self.package, i)
+
+ def __str__(self):
+ return "D=%s; R=%s; S=%s; E=%s, B=%s; RT=%s" %\
+ (self.depends, self.recommends, self.suggests,
+ self.enhances, self.breaks, self.rtscripts)
+
+ def depend(self, value):
+ if value and value not in self.depends:
+ self.depends.add(value)
+
+ def recommend(self, value):
+ if value and value not in self.recommends:
+ self.recommends.append(value)
+
+ def suggest(self, value):
+ if value and value not in self.suggests:
+ self.suggests.append(value)
+
+ def enhance(self, value):
+ if value and value not in self.enhances:
+ self.enhances.append(value)
+
+ def break_(self, value):
+ if value and value not in self.breaks:
+ self.breaks.append(value)
+
+ def rtscript(self, value):
+ if value not in self.rtscripts:
+ self.rtscripts.append(value)
+
+ def parse(self, stats, options):
+ log.debug('generating dependencies for package %s', self.package)
+ tpl = self.ipkg_tpl
+ vtpl = self.ipkg_vtpl
+ tpl_ma = self.ipkg_tpl_ma
+ vtpl_ma = self.ipkg_vtpl_ma
+ vrange = options.vrange
+
+ if vrange and any((stats['compile'], stats['public_vers'],
+ stats['ext_vers'], stats['ext_no_version'],
+ stats['shebangs'])):
+ if any((stats['compile'], stats['public_vers'], stats['shebangs'])):
+ tpl_tmp = tpl_ma
+ else:
+ tpl_tmp = tpl
+ minv = vrange.minver
+ # note it's an open interval (i.e. do not add 1 here!):
+ maxv = vrange.maxver
+ if minv == maxv:
+ self.depend(vtpl % minv)
+ minv = maxv = None
+ if minv:
+ self.depend("%s (>= %s~)" % (tpl_tmp, minv))
+ if maxv:
+ self.depend("%s (<< %s)" % (tpl_tmp, maxv))
+
+ if self.impl == 'cpython2' and stats['public_vers']:
+ # additional Depends to block python package transitions
+ sorted_vers = sorted(stats['public_vers'])
+ minv = sorted_vers[0]
+ maxv = sorted_vers[-1]
+ if minv <= default(self.impl):
+ self.depend("%s (>= %s~)" % (tpl_ma, minv))
+ if maxv >= default(self.impl):
+ self.depend("%s (<< %s)" % (tpl_ma, maxv + 1))
+
+ if self.impl == 'pypy' and stats.get('ext_soabi'):
+ # TODO: make sure alternative is used only for the same extension names
+ # ie. for foo.ABI1.so, foo.ABI2.so, bar.ABI3,so, bar.ABI4.so generate:
+ # pypy-abi-ABI1 | pypy-abi-ABI2, pypy-abi-ABI3 | pypy-abi-ABI4
+ self.depend('|'.join(soabi.replace('-', '-abi-')
+ for soabi in sorted(stats['ext_soabi'])))
+
+ if stats['ext_vers']:
+ # TODO: what about extensions with stable ABI?
+ sorted_vers = sorted(stats['ext_vers'])
+ minv = sorted_vers[0]
+ maxv = sorted_vers[-1]
+ #self.depend('|'.join(vtpl % i for i in stats['ext_vers']))
+ if minv <= default(self.impl):
+ self.depend("%s (>= %s~)" % (tpl, minv))
+ if maxv >= default(self.impl):
+ self.depend("%s (<< %s)" % (tpl, maxv + 1))
+
+ # make sure py{,3}compile binary is available
+ if stats['compile'] and self.impl in MINPYCDEP:
+ self.depend(MINPYCDEP[self.impl])
+
+ for ipreter in stats['shebangs']:
+ self.depend("%s%s" % (ipreter, '' if self.impl == 'pypy' else ':any'))
+
+ supported_versions = supported(self.impl)
+ default_version = default(self.impl)
+ for private_dir, details in stats['private_dirs'].items():
+ versions = list(i.version for i in details.get('shebangs', []) if i.version and i.version.minor)
+
+ for v in versions:
+ if v in supported_versions:
+ self.depend(vtpl_ma % v)
+ else:
+ log.info('dependency on %s (from shebang) ignored'
+ ' - it\'s not supported anymore', vtpl % v)
+ # /usr/bin/python{,3} shebang → add python{,3} to Depends
+ if any(True for i in details.get('shebangs', []) if i.version is None or i.version.minor is None):
+ self.depend(tpl_ma)
+
+ extensions = False
+ if self.python_dev_in_bd:
+ extensions = sorted(details.get('ext_vers', set()))
+ #self.depend('|'.join(vtpl % i for i in extensions))
+ if extensions:
+ self.depend("%s (>= %s~)" % (tpl, extensions[0]))
+ self.depend("%s (<< %s)" % (tpl, extensions[-1] + 1))
+ elif details.get('ext_no_version'):
+ # assume unrecognized extension was built for default interpreter version
+ self.depend("%s (>= %s~)" % (tpl, default_version))
+ self.depend("%s (<< %s)" % (tpl, default_version + 1))
+
+ if details.get('compile'):
+ if self.impl in MINPYCDEP:
+ self.depend(MINPYCDEP[self.impl])
+ args = ''
+ if extensions:
+ args += "-V %s" % VersionRange(minver=extensions[0], maxver=extensions[-1])
+ elif len(versions) == 1: # only one version from shebang
+ #if versions[0] in supported_versions:
+ args += "-V %s" % versions[0]
+ # ... otherwise compile with default version
+ elif details.get('ext_no_version'):
+ # assume unrecognized extension was built for default interpreter version
+ args += "-V %s" % default_version
+ elif vrange:
+ args += "-V %s" % vrange
+ if vrange.minver == vrange.maxver:
+ self.depend(vtpl % vrange.minver)
+ else:
+ if vrange.minver: # minimum version specified
+ self.depend("%s (>= %s~)" % (tpl_ma, vrange.minver))
+ if vrange.maxver: # maximum version specified
+ self.depend("%s (<< %s)" % (tpl_ma, vrange.maxver + 1))
+
+ for regex in options.regexpr or []:
+ args += " -X '%s'" % regex.pattern.replace("'", r"'\''")
+ self.rtscript((private_dir, args))
+
+ section_options = {
+ 'depends_sec': options.depends_section,
+ 'recommends_sec': options.recommends_section,
+ 'suggests_sec': options.suggests_section,
+ }
+ guess_deps = partial(guess_dependency, impl=self.impl, bdep=self.bdep,
+ accept_upstream_versions=options.accept_upstream_versions)
+ if options.guess_deps:
+ for fn in stats['requires.txt']:
+ # TODO: should options.recommends and options.suggests be
+ # removed from requires.txt?
+ deps = parse_pydep(self.impl, fn, bdep=self.bdep, **section_options)
+ [self.depend(i) for i in deps['depends']]
+ [self.recommend(i) for i in deps['recommends']]
+ [self.suggest(i) for i in deps['suggests']]
+ for fpath in stats['egg-info']:
+ with open(fpath, 'r', encoding='utf-8') as fp:
+ for line in fp:
+ if line.startswith('Requires: '):
+ req = line[10:].strip()
+ self.depend(guess_deps(req=req))
+ for fpath in stats['dist-info']:
+ deps = parse_requires_dist(self.impl, fpath, bdep=self.bdep,
+ **section_options)
+ [self.depend(i) for i in deps['depends']]
+ [self.recommend(i) for i in deps['recommends']]
+ [self.suggest(i) for i in deps['suggests']]
+
+ # add dependencies from --depends
+ for item in options.depends or []:
+ self.depend(guess_deps(req=item))
+ # add dependencies from --recommends
+ for item in options.recommends or []:
+ self.recommend(guess_deps(req=item))
+ # add dependencies from --suggests
+ for item in options.suggests or []:
+ self.suggest(guess_deps(req=item))
+ # add dependencies from --requires
+ for fn in options.requires or []:
+ fpath = join('debian', self.package, fn)
+ if not exists(fpath):
+ fpath = fn
+ if not exists(fpath):
+ log.warn('cannot find requirements file: %s', fn)
+ continue
+ deps = parse_pydep(self.impl, fpath, bdep=self.bdep, **section_options)
+ [self.depend(i) for i in deps['depends']]
+ [self.recommend(i) for i in deps['recommends']]
+ [self.suggest(i) for i in deps['suggests']]
+
+ log.debug(self)
diff --git a/dhpython/exceptions.py b/dhpython/exceptions.py
new file mode 100644
index 0000000..085bb89
--- /dev/null
+++ b/dhpython/exceptions.py
@@ -0,0 +1,23 @@
+# Copyright © 2022 Stefano Rivera <stefanor@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.
+
+
+class RequiredCommandMissingException(Exception):
+ pass
diff --git a/dhpython/fs.py b/dhpython/fs.py
new file mode 100644
index 0000000..445422e
--- /dev/null
+++ b/dhpython/fs.py
@@ -0,0 +1,587 @@
+# Copyright © 2013-2019 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 difflib
+import hashlib
+import logging
+import os
+import re
+import sys
+from filecmp import cmp as cmpfile
+from glob import glob
+from os.path import (lexists, exists, getsize, isdir, islink, join, realpath,
+ relpath, split, splitext)
+from shutil import rmtree
+from stat import ST_MODE, S_IXUSR, S_IXGRP, S_IXOTH
+from dhpython import MULTIARCH_DIR_TPL
+from dhpython.tools import fix_shebang, clean_egg_name
+from dhpython.interpreter import Interpreter
+
+log = logging.getLogger('dhpython')
+
+
+def fix_locations(package, interpreter, versions, options):
+ """Move files to the right location."""
+ # make a copy since we change version later
+ interpreter = Interpreter(interpreter)
+
+ for version in versions:
+ interpreter.version = version
+
+ dstdir = interpreter.sitedir(package)
+ for srcdir in interpreter.old_sitedirs(package):
+ if isdir(srcdir):
+ # TODO: what about relative symlinks?
+ log.debug('moving files from %s to %s', srcdir, dstdir)
+ share_files(srcdir, dstdir, interpreter, options)
+ try:
+ os.removedirs(srcdir)
+ except OSError:
+ pass
+
+ # do the same with debug locations
+ dstdir = interpreter.sitedir(package, gdb=True)
+ for srcdir in interpreter.old_sitedirs(package, gdb=True):
+ if isdir(srcdir):
+ log.debug('moving files from %s to %s', srcdir, dstdir)
+ share_files(srcdir, dstdir, interpreter, options)
+ try:
+ os.removedirs(srcdir)
+ except OSError:
+ pass
+
+ # move files from /usr/include/pythonX.Y/ to …/pythonX.Ym/
+ if interpreter.symlinked_include_dir:
+ srcdir = "debian/%s%s" % (package, interpreter.symlinked_include_dir)
+ if srcdir and isdir(srcdir):
+ dstdir = "debian/%s%s" % (package, interpreter.include_dir)
+ log.debug('moving files from %s to %s', srcdir, dstdir)
+ share_files(srcdir, dstdir, interpreter, options)
+ try:
+ os.removedirs(srcdir)
+ except OSError:
+ pass
+
+
+def share_files(srcdir, dstdir, interpreter, options):
+ """Try to move as many files from srcdir to dstdir as possible."""
+ cleanup_actions = []
+ for i in os.listdir(srcdir):
+ fpath1 = join(srcdir, i)
+ if not lexists(fpath1): # removed in rename_ext
+ continue
+ if i.endswith('.pyc'): # f.e. when tests were invoked on installed files
+ os.remove(fpath1)
+ continue
+ if not options.no_ext_rename and splitext(i)[-1] == '.so':
+ # try to rename extension here as well (in :meth:`scan` info about
+ # Python version is gone)
+ version = interpreter.parse_public_dir(srcdir)
+ if version and version is not True:
+ fpath1 = Scan.rename_ext(fpath1, interpreter, version)
+ i = split(fpath1)[-1]
+ if srcdir.endswith(".dist-info"):
+ if i in ('COPYING', 'LICENSE') or i.startswith(
+ ('COPYING.', 'LICENSE.')):
+ os.remove(fpath1)
+ cleanup_actions.append((remove_from_RECORD, ([i],)))
+ continue
+ elif isdir(fpath1) and i in ('licenses', 'license_files'):
+ cleanup_actions.append((
+ remove_from_RECORD,
+ ([
+ relpath(license, srcdir)
+ for license in glob(join(srcdir, i, '**'))
+ ],)
+ ))
+ rmtree(fpath1)
+ continue
+ fpath2 = join(dstdir, i)
+ if not isdir(fpath1) and not exists(fpath2):
+ # do not rename directories here - all .so files have to be renamed first
+ os.renames(fpath1, fpath2)
+ continue
+ if islink(fpath1):
+ # move symlinks without changing them if they point to the same place
+ if not exists(fpath2):
+ os.renames(fpath1, fpath2)
+ elif realpath(fpath1) == realpath(fpath2):
+ os.remove(fpath1)
+ elif isdir(fpath1):
+ share_files(fpath1, fpath2, interpreter, options)
+ elif cmpfile(fpath1, fpath2, shallow=False):
+ os.remove(fpath1)
+ elif i.endswith(('.abi3.so', '.abi4.so')) and interpreter.parse_public_dir(srcdir):
+ log.warning('%s differs from previous one, removing anyway (%s)', i, srcdir)
+ os.remove(fpath1)
+ elif srcdir.endswith(".dist-info"):
+ # dist-info file that differs... try merging
+ if i == "WHEEL":
+ if merge_WHEEL(fpath1, fpath2):
+ cleanup_actions.append((fix_merged_RECORD, ()))
+ os.remove(fpath1)
+ elif i == "RECORD":
+ merge_RECORD(fpath1, fpath2)
+ os.remove(fpath1)
+ else:
+ log.warn("No merge driver for dist-info file %s", i)
+ else:
+ # The files differed so we cannot collapse them.
+ log.warn('Paths differ: %s and %s', fpath1, fpath2)
+ if options.verbose and not i.endswith(('.so', '.a')):
+ with open(fpath1) as fp1:
+ fromlines = fp1.readlines()
+ with open(fpath2) as fp2:
+ tolines = fp2.readlines()
+ diff = difflib.unified_diff(fromlines, tolines, fpath1, fpath2)
+ sys.stderr.writelines(diff)
+
+ for action, args in cleanup_actions:
+ action(dstdir, *args)
+ try:
+ os.removedirs(srcdir)
+ except OSError:
+ pass
+
+
+## Functions to merge parts of the .dist-info metadata directory together
+
+def missing_lines(src, dst):
+ """Find all the lines in the text file src that are not in dst"""
+ with open(dst) as fh:
+ current = {k: None for k in fh.readlines()}
+
+ missing = []
+ with open(src) as fh:
+ for line in fh.readlines():
+ if line not in current:
+ missing.append(line)
+
+ return missing
+
+
+def merge_WHEEL(src, dst):
+ """Merge the source .dist-info/WHEEL file into the destination
+
+ Note that after editing the WHEEL file, the sha256 included in
+ the .dist-info/RECORD file will be incorrect and will need fixing
+ using the fix_merged_RECORD() function.
+ """
+ log.debug("Merging WHEEL file %s into %s", src, dst)
+ missing = missing_lines(src, dst)
+ with open(dst, "at") as fh:
+ for line in missing:
+ if line.startswith("Tag: "):
+ fh.write(line)
+ else:
+ log.warn("WHEEL merge discarded line %s", line)
+
+ return len(missing)
+
+
+def merge_RECORD(src, dst):
+ """Merge the source .dist-info/RECORD file into the destination"""
+ log.debug("Merging RECORD file %s into %s", src, dst)
+ missing = missing_lines(src, dst)
+
+ with open(dst, "at") as fh:
+ for line in missing:
+ fh.write(line)
+
+ return len(missing)
+
+
+def fix_merged_RECORD(distdir):
+ """Update the checksum for .dist-info/WHEEL in .dist-info/RECORD
+
+ After merging the .dist-info/WHEEL file, the sha256 recorded for it will be
+ wrong in .dist-info/RECORD, so edit that file to ensure that it is fixed.
+ The output is sorted for reproducibility.
+ """
+ log.debug("Fixing RECORD file in %s", distdir)
+ record_path = join(distdir, "RECORD")
+ wheel_path = join(distdir, "WHEEL")
+ wheel_dir = split(split(record_path)[0])[1]
+ wheel_relpath = join(wheel_dir, "WHEEL")
+
+ with open(wheel_path, "rb") as fh:
+ wheel_sha256 = hashlib.sha256(fh.read()).hexdigest();
+ wheel_size = getsize(wheel_path)
+
+ contents = [
+ "{name},sha256={sha256sum},{size}\n".format(
+ name=wheel_relpath,
+ sha256sum=wheel_sha256,
+ size=wheel_size,
+ )]
+ with open(record_path) as fh:
+ for line in fh.readlines():
+ if not line.startswith(wheel_relpath):
+ contents.append(line)
+ # now write out the updated record
+ with open(record_path, "wt") as fh:
+ fh.writelines(sorted(contents))
+
+
+def remove_from_RECORD(distdir, files):
+ """Remove all specified dist-info files from RECORD"""
+ log.debug("Removing %r from RECORD in %s", files, distdir)
+ record = join(distdir, "RECORD")
+ parent_dir = split(distdir)[1]
+ names = [join(parent_dir, name) for name in files]
+ lines = []
+ with open(record) as fh:
+ lines = fh.readlines()
+
+ filtered = [line for line in lines if not line.split(',', 1)[0] in names]
+
+ if lines == filtered:
+ log.warn("Unable to remove %r from RECORD in %s, not found",
+ files, distdir)
+
+ with open(record, 'wt') as fh:
+ fh.writelines(sorted(filtered))
+
+
+class Scan:
+ UNWANTED_DIRS = re.compile(r'.*/__pycache__(/.*)?$')
+ UNWANTED_FILES = re.compile(r'.*\.py[co]$')
+
+ def __init__(self, interpreter, package, dpath=None, options=None):
+ self.interpreter = interpreter
+ self.impl = interpreter.impl
+
+ self.package = package
+
+ if not dpath:
+ self.proot = "debian/%s" % self.package
+ else:
+ dpath = dpath.strip('/')
+ self.proot = join('debian', self.package, dpath)
+ self.dpath = dpath
+ del dpath
+
+ self.options = options
+ self.result = {'requires.txt': set(),
+ 'egg-info': set(),
+ 'dist-info': set(),
+ 'nsp.txt': set(),
+ 'shebangs': set(),
+ 'public_vers': set(),
+ 'private_dirs': {},
+ 'compile': False,
+ 'ext_vers': set(),
+ 'ext_no_version': set()}
+
+ for root, dirs, file_names in os.walk(self.proot):
+ if interpreter.should_ignore(root):
+ del dirs[:]
+ continue
+
+ self.current_private_dir = self.current_pub_version = None
+ version = interpreter.parse_public_dir(root)
+ if version:
+ self.current_dir_is_public = True
+ if version is True:
+ version = None
+ else:
+ self.current_pub_version = version
+ else:
+ self.current_dir_is_public = False
+
+ if self.current_dir_is_public:
+ if root.endswith('-packages'):
+ if version is not None:
+ self.result['public_vers'].add(version)
+ for name in dirs:
+ if name in ('test', 'tests') or name.startswith('.'):
+ log.debug('removing dist-packages/%s', name)
+ rmtree(join(root, name))
+ dirs.remove(name)
+ else:
+ self.current_private_dir = self.check_private_dir(root)
+ if not self.current_private_dir:
+ # i.e. not a public dir and not a private dir
+ if self.is_bin_dir(root):
+ self.handle_bin_dir(root, file_names)
+ else: # not a public, private or bin directory
+ # continue with a subdirectory
+ continue
+
+ for name in dirs:
+ dpath = join(root, name)
+ if self.is_unwanted_dir(dpath):
+ rmtree(dpath)
+ dirs.remove(name)
+ continue
+
+ if self.is_dist_dir(root):
+ self.handle_dist_dir(root, file_names)
+ continue
+
+ if self.is_egg_dir(root):
+ self.handle_egg_dir(root, file_names)
+ continue
+
+ # check files
+ for fn in sorted(file_names):
+ # sorted() to make sure .so files are handled before .so.foo
+ fpath = join(root, fn)
+
+ if self.is_unwanted_file(fpath):
+ log.debug('removing unwanted: %s', fpath)
+ os.remove(fpath)
+ continue
+
+ if self.is_egg_file(fpath):
+ self.handle_egg_file(fpath)
+ continue
+
+ if not exists(fpath):
+ # possibly removed while handling .so symlinks
+ if islink(fpath) and '.so.' in split(fpath)[-1]:
+ # dangling symlink to (now removed/renamed) .so file
+ # which wasn't removed yet (see test203's quux.so.0)
+ log.info('removing dangling symlink: %s', fpath)
+ os.remove(fpath)
+ continue
+
+ fext = splitext(fn)[-1][1:]
+ if fext == 'so':
+ if not self.options.no_ext_rename:
+ fpath = self.rename_ext(fpath, interpreter, version)
+ ver = self.handle_ext(fpath)
+ ver = ver or version
+ if ver:
+ self.current_result.setdefault('ext_vers', set()).add(ver)
+ else:
+ self.current_result.setdefault('ext_no_version', set()).add(fpath)
+
+ if self.current_private_dir:
+ if exists(fpath) and fext != 'so':
+ mode = os.stat(fpath)[ST_MODE]
+ if mode & S_IXUSR or mode & S_IXGRP or mode & S_IXOTH:
+ if (options.no_shebang_rewrite or
+ fix_shebang(fpath, self.options.shebang)) and \
+ not self.options.ignore_shebangs:
+ try:
+ res = Interpreter.from_file(fpath)
+ except Exception as e:
+ log.debug('cannot parse shebang %s: %s', fpath, e)
+ else:
+ self.current_result.setdefault('shebangs', set()).add(res)
+
+ if fext == 'py' and self.handle_public_module(fpath) is not False:
+ self.current_result['compile'] = True
+
+ if not dirs and not self.current_private_dir:
+ try:
+ os.removedirs(root)
+ except OSError:
+ pass
+
+ log.debug("package %s details = %s", package, self.result)
+
+ @property
+ def current_result(self):
+ if self.current_private_dir:
+ return self.result['private_dirs'].setdefault(self.current_private_dir, {})
+ return self.result
+
+ def is_unwanted_dir(self, dpath):
+ return self.__class__.UNWANTED_DIRS.match(dpath)
+
+ def is_unwanted_file(self, fpath):
+ if self.__class__.UNWANTED_FILES.match(fpath):
+ return True
+ if self.current_dir_is_public and self.is_dbg_package\
+ and self.options.clean_dbg_pkg\
+ and splitext(fpath)[-1][1:] not in ('so', 'h'):
+ return True
+
+ @property
+ def private_dirs_to_check(self):
+ if self.dpath:
+ # scan private directory *only*
+ return [self.dpath]
+
+ if self.dpath is False:
+ result = []
+ else:
+ result = [i % self.package for i in (
+ 'usr/lib/%s',
+ 'usr/lib/games/%s',
+ 'usr/share/%s',
+ 'usr/share/games/%s')]
+ return result
+
+ @property
+ def is_dbg_package(self):
+ #return self.interpreter.debug
+ return self.package.endswith('-dbg')
+
+ def check_private_dir(self, dpath):
+ """Return private dir's root if it's a private dir."""
+ for i in self.private_dirs_to_check:
+ if dpath.startswith(join('debian', self.package, i)):
+ return '/' + i
+
+ @staticmethod
+ def rename_ext(fpath, interpreter, current_pub_version=None):
+ """Add multiarch triplet, etc. Return new name.
+
+ This method is invoked for all .so files in public or private directories.
+ """
+ # current_pub_version - version parsed from dist-packages (True if unversioned)
+ # i.e. if it's not None - it's a public dist-packages directory
+
+ path, fname = fpath.rsplit('/', 1)
+ if current_pub_version is not None and islink(fpath):
+ # replace symlinks with extensions in dist-packages directory
+ dstfpath = fpath
+ links = set()
+ while islink(dstfpath):
+ links.add(dstfpath)
+ dstfpath = join(path, os.readlink(dstfpath))
+ if exists(dstfpath) and '.so.' in split(dstfpath)[-1]:
+ # rename .so.$FOO symlinks, remove other ones
+ for lpath in links:
+ log.info('removing symlink: %s', lpath)
+ os.remove(lpath)
+ log.info('renaming %s to %s', dstfpath, fname)
+ os.rename(dstfpath, fpath)
+
+ if MULTIARCH_DIR_TPL.match(fpath):
+ # ignore /lib/i386-linux-gnu/, /usr/lib/x86_64-kfreebsd-gnu/, etc.
+ return fpath
+
+ new_fn = interpreter.check_extname(fname, current_pub_version)
+ if new_fn:
+ # TODO: what about symlinks pointing to this file
+ new_fpath = join(path, new_fn)
+ if exists(new_fpath):
+ log.warn('destination file exist, '
+ 'cannot rename %s to %s', fname, new_fn)
+ else:
+ log.info('renaming %s to %s', fname, new_fn)
+ os.rename(fpath, new_fpath)
+ return new_fpath
+ return fpath
+
+ def handle_ext(self, fpath):
+ """Handle .so file, return its version if detected."""
+
+ def handle_public_module(self, fpath):
+ pass
+
+ def is_bin_dir(self, dpath):
+ """Check if dir is one from PATH ones."""
+ # dname = debian/packagename/usr/games
+ spath = dpath.strip('/').split('/', 4)
+ if len(spath) > 4:
+ return False # assume bin directories don't have subdirectories
+ if dpath.endswith(('/sbin', '/bin', '/usr/games')):
+ # /(s)bin or /usr/(s)bin or /usr/games
+ return True
+
+ def handle_bin_dir(self, dpath, file_names):
+ if self.options.no_shebang_rewrite or self.options.ignore_shebangs:
+ return
+ for fn in file_names:
+ fpath = join(dpath, fn)
+ if fix_shebang(fpath, self.options.shebang):
+ try:
+ res = Interpreter.from_file(fpath)
+ except Exception as e:
+ log.debug('cannot parse shebang %s: %s', fpath, e)
+ else:
+ self.result['shebangs'].add(res)
+
+ def is_egg_dir(self, dname):
+ """Check if given directory contains egg-info."""
+ return dname.endswith('.egg-info')
+
+ def handle_egg_dir(self, dpath, file_names):
+ path, dname = dpath.rsplit('/', 1)
+ if self.is_dbg_package and self.options.clean_dbg_pkg:
+ rmtree(dpath)
+ return
+
+ clean_name = clean_egg_name(dname)
+ if clean_name != dname:
+ if exists(join(path, clean_name)):
+ log.info('removing %s (%s is already available)', dname, clean_name)
+ rmtree(dpath)
+ return
+ else:
+ log.info('renaming %s to %s', dname, clean_name)
+ os.rename(dpath, join(path, clean_name))
+ dname = clean_name
+ dpath = join(path, dname)
+ if file_names:
+ if 'requires.txt' in file_names:
+ self.result['requires.txt'].add(join(dpath, 'requires.txt'))
+ if 'namespace_packages.txt' in file_names:
+ self.result['nsp.txt'].add(join(dpath, 'namespace_packages.txt'))
+ if 'SOURCES.txt' in file_names:
+ os.remove(join(dpath, 'SOURCES.txt'))
+ file_names.remove('SOURCES.txt')
+
+ def is_egg_file(self, fpath):
+ """Check if given file contains egg-info."""
+ return fpath.endswith('.egg-info')
+
+ def handle_egg_file(self, fpath):
+ root, name = fpath.rsplit('/', 1)
+ clean_name = clean_egg_name(name)
+ if clean_name != name:
+ if exists(join(root, clean_name)):
+ log.info('removing %s (%s is already available)',
+ name, clean_name)
+ os.remove(fpath)
+ else:
+ log.info('renaming %s to %s', name, clean_name)
+ os.rename(fpath, join(root, clean_name))
+ self.result['egg-info'].add(join(root, clean_name))
+
+ def is_dist_dir(self, dname):
+ """Check if given directory contains dist-info."""
+ return dname.endswith('.dist-info')
+
+ def handle_dist_dir(self, dpath, file_names):
+ path, dname = dpath.rsplit('/', 1)
+ if self.is_dbg_package and self.options.clean_dbg_pkg:
+ rmtree(dpath)
+ return
+
+ if file_names:
+ if 'METADATA' in file_names:
+ self.result['dist-info'].add(join(dpath, 'METADATA'))
+
+ def cleanup(self):
+ if self.is_dbg_package and self.options.clean_dbg_pkg:
+ # remove empty directories in -dbg packages
+ proot = self.proot + '/usr/lib'
+ for root, dirs, file_names in os.walk(proot, topdown=False):
+ if '-packages/' in root and not file_names:
+ try:
+ os.removedirs(root)
+ except Exception:
+ pass
diff --git a/dhpython/interpreter.py b/dhpython/interpreter.py
new file mode 100644
index 0000000..021e847
--- /dev/null
+++ b/dhpython/interpreter.py
@@ -0,0 +1,576 @@
+# 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
+import os
+import re
+from os.path import exists, join, split
+from dhpython import INTERPRETER_DIR_TPLS, PUBLIC_DIR_RE, OLD_SITE_DIRS
+
+SHEBANG_RE = re.compile(r'''
+ (?:\#!\s*){0,1} # shebang prefix
+ (?P<path>
+ .*?/bin/.*?)?
+ (?P<name>
+ python|pypy)
+ (?P<version>
+ \d[\.\d]*)?
+ (?P<debug>
+ -dbg)?
+ (?P<options>.*)
+ ''', re.VERBOSE)
+EXTFILE_RE = re.compile(r'''
+ (?P<name>.*?)
+ (?:\.
+ (?P<stableabi>abi\d+)
+ |(?:\.
+ (?P<soabi>
+ (?P<impl>cpython|pypy)
+ -
+ (?P<ver>\d{2,})
+ (?P<flags>[a-z]*)
+ )?
+ (?:
+ (?:(?<!\.)-)? # minus sign only if soabi is defined
+ (?P<multiarch>[^/]*?)
+ )?
+ ))?
+ (?P<debug>_d)?
+ \.so$''', re.VERBOSE)
+log = logging.getLogger('dhpython')
+
+
+class Interpreter:
+ """
+ :attr path: /usr/bin/ in most cases
+ :attr name: pypy or python (even for python3 and python-dbg) or empty string
+ :attr version: interpreter's version
+ :attr debug: -dbg version of the interpreter
+ :attr impl: implementation (cpytho2, cpython3 or pypy)
+ :attr options: options parsed from shebang
+ :type path: str
+ :type name: str
+ :type version: Version or None
+ :type debug: bool
+ :type impl: str
+ :type options: tuple
+ """
+ path = '/usr/bin/'
+ name = 'python'
+ version = None
+ debug = False
+ impl = ''
+ options = ()
+ _cache = {}
+
+ def __init__(self, value=None, path=None, name=None, version=None,
+ debug=None, impl=None, options=None):
+ params = locals()
+ del params['self']
+ del params['value']
+
+ if isinstance(value, Interpreter):
+ for key in params.keys():
+ if params[key] is None:
+ params[key] = getattr(value, key)
+ elif value:
+ if value.replace('.', '').isdigit() and not version:
+ # version string
+ params['version'] = Version(value)
+ else:
+ # shebang or other string
+ for key, val in self.parse(value).items():
+ # prefer values passed to constructor over shebang ones:
+ if params[key] is None:
+ params[key] = val
+
+ for key, val in params.items():
+ if val is not None:
+ setattr(self, key, val)
+ elif key == 'version':
+ setattr(self, key, val)
+
+ def __setattr__(self, name, value):
+ if name == 'name':
+ if value not in ('python', 'pypy', ''):
+ raise ValueError("interpreter not supported: %s" % value)
+ if value == 'python':
+ if self.version:
+ if self.version.major == 3:
+ self.__dict__['impl'] = 'cpython3'
+ else:
+ self.__dict__['impl'] = 'cpython2'
+ elif value == 'pypy':
+ self.__dict__['impl'] = 'pypy'
+ elif name == 'version' and value is not None:
+ value = Version(value)
+ if not self.impl and self.name == 'python':
+ if value.major == 3:
+ self.impl = 'cpython3'
+ else:
+ self.impl = 'cpython2'
+ if name in ('path', 'name', 'impl', 'options') and value is None:
+ pass
+ elif name == 'debug':
+ self.__dict__[name] = bool(value)
+ else:
+ self.__dict__[name] = value
+
+ def __repr__(self):
+ result = self.path
+ if not result.endswith('/'):
+ result += '/'
+ result += self._vstr(self.version)
+ if self.options:
+ result += ' ' + ' '.join(self.options)
+ return result
+
+ def __str__(self):
+ return self._vstr(self.version)
+
+ def _vstr(self, version=None, consider_default_ver=False):
+ if self.impl == 'pypy':
+ # TODO: will Debian support more than one PyPy version?
+ return self.name
+ version = version or self.version or ''
+ if consider_default_ver and (not version or version == self.default_version):
+ version = '3' if self.impl == 'cpython3' else '2'
+ if self.debug:
+ return 'python{}-dbg'.format(version)
+ return self.name + str(version)
+
+ def binary(self, version=None):
+ return '{}{}'.format(self.path, self._vstr(version))
+
+ @property
+ def binary_dv(self):
+ """Like binary(), but returns path to default intepreter symlink
+ if version matches default one for given implementation.
+ """
+ return '{}{}'.format(self.path, self._vstr(consider_default_ver=True))
+
+ @property
+ def default_version(self):
+ if self.impl:
+ return default(self.impl)
+
+ @staticmethod
+ def parse(shebang):
+ """Return dict with parsed shebang
+
+ >>> sorted(Interpreter.parse('/usr/bin/python3.2-dbg').items())
+ [('debug', '-dbg'), ('name', 'python'), ('options', ()), ('path', '/usr/bin/'), ('version', '3.2')]
+ >>> sorted(Interpreter.parse('#! /usr/bin/python3.2').items())
+ [('debug', None), ('name', 'python'), ('options', ()), ('path', '/usr/bin/'), ('version', '3.2')]
+ >>> sorted(Interpreter.parse('/usr/bin/python3.2-dbg --foo --bar').items())
+ [('debug', '-dbg'), ('name', 'python'), ('options', ('--foo', '--bar')),\
+ ('path', '/usr/bin/'), ('version', '3.2')]
+ """
+ result = SHEBANG_RE.search(shebang)
+ if not result:
+ return {}
+ result = result.groupdict()
+ if 'options' in result:
+ # TODO: do we need "--key value" here?
+ result['options'] = tuple(result['options'].split())
+ if result['name'] == 'python' and result['version'] is None:
+ result['version'] = '2'
+ return result
+
+ @classmethod
+ def from_file(cls, fpath):
+ """Read file's shebang and parse it."""
+ interpreter = Interpreter()
+ with open(fpath, 'rb') as fp:
+ data = fp.read(96)
+ if b"\0" in data:
+ raise ValueError('cannot parse binary file')
+ # make sure only first line is checkeed
+ data = str(data, 'utf-8').split('\n')[0]
+ if not data.startswith('#!'):
+ raise ValueError("doesn't look like a shebang: %s" % data)
+
+ parsed = cls.parse(data)
+ if not parsed:
+ raise ValueError("doesn't look like a shebang: %s" % data)
+ for key, val in parsed.items():
+ setattr(interpreter, key, val)
+ return interpreter
+
+ def sitedir(self, package=None, version=None, gdb=False):
+ """Return path to site-packages directory.
+
+ Note that returned path is not the final location of .py files
+
+ >>> i = Interpreter('python')
+ >>> i.sitedir(version='3.1')
+ '/usr/lib/python3/dist-packages/'
+ >>> i.sitedir(version='2.5')
+ '/usr/lib/python2.5/site-packages/'
+ >>> i.sitedir(version=Version('2.7'))
+ '/usr/lib/python2.7/dist-packages/'
+ >>> i.sitedir(version='3.1', gdb=True, package='python3-foo')
+ 'debian/python3-foo/usr/lib/debug/usr/lib/python3/dist-packages/'
+ >>> i.sitedir(version=Version('3.2'))
+ '/usr/lib/python3/dist-packages/'
+ """
+ try:
+ version = Version(version or self.version)
+ except Exception as err:
+ raise ValueError("cannot find valid version: %s" % err)
+ if self.impl == 'pypy':
+ path = '/usr/lib/pypy/dist-packages/'
+ elif version << Version('2.6'):
+ path = "/usr/lib/python%s/site-packages/" % version
+ elif version << Version('3.0'):
+ path = "/usr/lib/python%s/dist-packages/" % version
+ else:
+ path = '/usr/lib/python3/dist-packages/'
+
+ if gdb:
+ path = "/usr/lib/debug%s" % path
+ if package:
+ path = "debian/%s%s" % (package, path)
+
+ return path
+
+ def old_sitedirs(self, package=None, version=None, gdb=False):
+ """Return deprecated paths to site-packages directories."""
+ try:
+ version = Version(version or self.version)
+ except Exception as err:
+ raise ValueError("cannot find valid version: %s" % err)
+ result = []
+ for item in OLD_SITE_DIRS.get(self.impl, []):
+ if isinstance(item, str):
+ result.append(item.format(version))
+ else:
+ res = item(version)
+ if res is not None:
+ result.append(res)
+
+ if gdb:
+ result = ['/usr/lib/debug{}'.format(i) for i in result]
+ if self.impl.startswith('cpython'):
+ result.append('/usr/lib/debug/usr/lib/pyshared/python{}'.format(version))
+ if package:
+ result = ['debian/{}{}'.format(package, i) for i in result]
+
+ return result
+
+ def parse_public_dir(self, path):
+ """Return version assigned to site-packages path
+ or True is it's unversioned public dir."""
+ match = PUBLIC_DIR_RE[self.impl].match(path)
+ if match:
+ vers = match.groups(0)
+ if vers and vers[0]:
+ return Version(vers)
+ return True
+
+ def should_ignore(self, path):
+ """Return True if path is used by another interpreter implementation."""
+ cache_key = 'should_ignore_{}'.format(self.impl)
+ if cache_key not in self.__class__._cache:
+ expr = [v for k, v in INTERPRETER_DIR_TPLS.items() if k != self.impl]
+ regexp = re.compile('|'.join('({})'.format(i) for i in expr))
+ self.__class__._cache[cache_key] = regexp
+ else:
+ regexp = self.__class__._cache[cache_key]
+ return regexp.search(path)
+
+ def cache_file(self, fpath, version=None):
+ """Given path to a .py file, return path to its .pyc/.pyo file.
+
+ This function is inspired by Python 3.2's imp.cache_from_source.
+
+ :param fpath: path to file name
+ :param version: Python version
+
+ >>> i = Interpreter('python')
+ >>> i.cache_file('foo.py', Version('3.1'))
+ 'foo.pyc'
+ >>> i.cache_file('bar/foo.py', '3.8') # doctest: +SKIP
+ 'bar/__pycache__/foo.cpython-38.pyc'
+ """
+ version = Version(version or self.version)
+ last_char = 'o' if '-O' in self.options else 'c'
+ if version <= Version('3.1'):
+ return fpath + last_char
+
+ fdir, fname = split(fpath)
+ if not fname.endswith('.py'):
+ fname += '.py'
+ return join(fdir, '__pycache__', "%s.%s.py%s" %
+ (fname[:-3], self.magic_tag(version), last_char))
+
+ def magic_number(self, version=None):
+ """Return magic number."""
+ version = Version(version or self.version)
+ if self.impl == 'cpython2':
+ return ''
+ result = self._execute('import imp; print(imp.get_magic())', version)
+ return eval(result)
+
+ def magic_tag(self, version=None):
+ """Return Python magic tag (used in __pycache__ dir to tag files).
+
+ >>> i = Interpreter('python')
+ >>> i.magic_tag(version='3.8') # doctest: +SKIP
+ 'cpython-38'
+ """
+ version = Version(version or self.version)
+ if self.impl.startswith('cpython') and version << Version('3.2'):
+ return ''
+ return self._execute('import imp; print(imp.get_tag())', version)
+
+ def multiarch(self, version=None):
+ """Return multiarch tag."""
+ version = Version(version or self.version)
+ try:
+ soabi, multiarch = self._get_config(version)[:2]
+ except Exception:
+ log.debug('cannot get multiarch', exc_info=True)
+ # interpreter without multiarch support
+ return ''
+ return multiarch
+
+ def stableabi(self, version=None):
+ version = Version(version or self.version)
+ # stable ABI was introduced in Python 3.3
+ if self.impl == 'cpython3' and version >> Version('3.2'):
+ return 'abi{}'.format(version.major)
+
+ def soabi(self, version=None):
+ """Return SOABI flag (used to in .so files)."""
+ version = Version(version or self.version)
+ # NOTE: it's not the same as magic_tag
+ try:
+ soabi, multiarch = self._get_config(version)[:2]
+ except Exception:
+ log.debug('cannot get soabi', exc_info=True)
+ # interpreter without soabi support
+ return ''
+ return soabi
+
+ @property
+ def include_dir(self):
+ """Return INCLUDE_DIR path.
+
+ >>> Interpreter('python2.7').include_dir # doctest: +SKIP
+ '/usr/include/python2.7'
+ >>> Interpreter('python3.8-dbg').include_dir # doctest: +SKIP
+ '/usr/include/python3.8d'
+ """
+ if self.impl == 'pypy':
+ return '/usr/lib/pypy/include'
+ try:
+ result = self._get_config()[2]
+ if result:
+ return result
+ except Exception:
+ result = ''
+ log.debug('cannot get include path', exc_info=True)
+ result = '/usr/include/{}'.format(self.name)
+ version = self.version
+ if self.debug:
+ if version >= '3.8':
+ result += 'd'
+ elif version << '3.3':
+ result += '_d'
+ else:
+ result += 'dm'
+ else:
+ if version >= '3.8':
+ pass
+ elif version >> '3.2':
+ result += 'm'
+ elif version == '3.2':
+ result += 'mu'
+ return result
+
+ @property
+ def symlinked_include_dir(self):
+ """Return path to symlinked include directory."""
+ if self.impl in ('cpython2', 'pypy') or self.debug \
+ or self.version >> '3.7' or self.version << '3.3':
+ # these interpreters do not provide symlink,
+ # others provide it in libpython3.X-dev
+ return
+ try:
+ result = self._get_config()[2]
+ if result:
+ if result.endswith('m'):
+ return result[:-1]
+ else:
+ # there's include_dir, but no "m"
+ return
+ except Exception:
+ result = '/usr/include/{}'.format(self.name)
+ log.debug('cannot get include path', exc_info=True)
+ return result
+
+ @property
+ def library_file(self):
+ """Return libfoo.so file path."""
+ if self.impl == 'pypy':
+ return ''
+ libpl, ldlibrary = self._get_config()[3:5]
+ if ldlibrary.endswith('.a'):
+ # python3.1-dbg, python3.2, python3.2-dbg returned static lib
+ ldlibrary = ldlibrary.replace('.a', '.so')
+ if libpl and ldlibrary:
+ return join(libpl, ldlibrary)
+ raise Exception('cannot find library file for {}'.format(self))
+
+ def check_extname(self, fname, version=None):
+ """Return extension file name if file can be renamed."""
+ if not version and not self.version:
+ return
+
+ version = Version(version or self.version)
+
+ if '/' in fname:
+ fdir, fname = fname.rsplit('/', 1) # in case full path was passed
+ else:
+ fdir = ''
+
+ info = EXTFILE_RE.search(fname)
+ if not info:
+ return
+ info = info.groupdict()
+ if info['ver'] and (not version or version.minor is None):
+ # get version from soabi if version is not set of only major
+ # version number is set
+ version = Version("%s.%s" % (info['ver'][0], info['ver'][1]))
+
+ if info['stableabi']:
+ # files with stable ABI in name don't need changes
+ return
+ if info['debug'] and self.debug is False:
+ # do not change Python 2.X extensions already marked as debug
+ # (the other way around is acceptable)
+ return
+ if info['soabi'] and info['multiarch']:
+ # already tagged, nothing we can do here
+ return
+
+ try:
+ soabi, multiarch = self._get_config(version)[:2]
+ except Exception:
+ log.debug('cannot get soabi/multiarch', exc_info=True)
+ return
+
+ if info['soabi'] and soabi and info['soabi'] != soabi:
+ return
+
+ tmp_soabi = info['soabi'] or soabi
+ tmp_multiarch = info['multiarch'] or multiarch
+
+ result = info['name']
+ if result.endswith('module') and result != 'module' and (
+ self.impl == 'cpython3' and version >> '3.2' or
+ self.impl == 'cpython2' and version == '2.7'):
+ result = result[:-6]
+
+ if tmp_soabi:
+ result = "{}.{}".format(result, tmp_soabi)
+ if tmp_multiarch and not (self.impl == 'cpython3' and version << '3.3') and tmp_multiarch not in soabi:
+ result = "{}-{}".format(result, tmp_multiarch)
+ elif self.impl == 'cpython2' and version == '2.7' and tmp_multiarch:
+ result = "{}.{}".format(result, tmp_multiarch)
+
+ if self.debug and self.impl == 'cpython2':
+ result += '_d'
+ result += '.so'
+ if fname == result:
+ return
+ return join(fdir, result)
+
+ def suggest_pkg_name(self, name):
+ """Suggest binary package name with for given library name
+
+ >>> Interpreter('python3.1').suggest_pkg_name('foo')
+ 'python3-foo'
+ >>> Interpreter('python3.8').suggest_pkg_name('foo_bar')
+ 'python3-foo-bar'
+ >>> Interpreter('python2.7-dbg').suggest_pkg_name('bar')
+ 'python-bar-dbg'
+ """
+ name = name.replace('_', '-')
+ if self.impl == 'pypy':
+ return 'pypy-{}'.format(name)
+ version = '3' if self.impl == 'cpython3' else ''
+ result = 'python{}-{}'.format(version, name)
+ if self.debug:
+ result += '-dbg'
+ return result
+
+ def _get_config(self, version=None):
+ version = Version(version or self.version)
+ # sysconfig module is available since Python 3.2
+ # (also backported to Python 2.7)
+ if self.impl == 'pypy' or self.impl.startswith('cpython') and (
+ version >> '2.6' and version << '3'
+ or version >> '3.1' or version == '3'):
+ cmd = 'import sysconfig as s;'
+ else:
+ cmd = 'from distutils import sysconfig as s;'
+ cmd += 'print("__SEP__".join(i or "" ' \
+ 'for i in s.get_config_vars('\
+ '"SOABI", "MULTIARCH", "INCLUDEPY", "LIBPL", "LDLIBRARY")))'
+ conf_vars = self._execute(cmd, version).split('__SEP__')
+ if conf_vars[1] in conf_vars[0]:
+ # Python >= 3.5 includes MILTIARCH in SOABI
+ conf_vars[0] = conf_vars[0].replace("-%s" % conf_vars[1], '')
+ try:
+ conf_vars[1] = os.environ['DEB_HOST_MULTIARCH']
+ except KeyError:
+ pass
+ return conf_vars
+
+ def _execute(self, command, version=None, cache=True):
+ version = Version(version or self.version)
+ exe = "{}{}".format(self.path, self._vstr(version))
+ command = "{} -c '{}'".format(exe, command.replace("'", "\'"))
+ if cache and command in self.__class__._cache:
+ return self.__class__._cache[command]
+ if not exists(exe):
+ raise Exception("cannot execute command due to missing "
+ "interpreter: %s" % exe)
+
+ output = execute(command)
+ if output['returncode'] != 0:
+ log.debug(output['stderr'])
+ raise Exception('{} failed with status code {}'.format(command, output['returncode']))
+
+ result = output['stdout'].splitlines()
+
+ if len(result) == 1:
+ result = result[0]
+
+ if cache:
+ self.__class__._cache[command] = result
+
+ return result
+
+# due to circular imports issue
+from dhpython.tools import execute
+from dhpython.version import Version, default
diff --git a/dhpython/markers.py b/dhpython/markers.py
new file mode 100644
index 0000000..a0a3e55
--- /dev/null
+++ b/dhpython/markers.py
@@ -0,0 +1,70 @@
+# Copyright © 2022 Stefano Rivera <stefanor@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.
+
+"""
+Handle Environment Markers
+https://www.python.org/dev/peps/pep-0508/#environment-markers
+
+TODO: Ideally replace with the packaging library, but the API is currently
+private: https://github.com/pypa/packaging/issues/496
+"""
+
+import re
+
+
+SIMPLE_ENV_MARKER_RE = re.compile(r'''
+ (?P<marker>[a-z_]+)
+ \s*
+ (?P<op><=?|>=?|[=!~]=|===)
+ \s*
+ (?P<quote>['"])
+ (?P<value>.*) # Could contain additional markers
+ (?P=quote)
+ ''', re.VERBOSE)
+COMPLEX_ENV_MARKER_RE = re.compile(r'''
+ (?:\s|\))
+ (?:and|or)
+ (?:\s|\()
+ ''', re.VERBOSE)
+
+
+class ComplexEnvironmentMarker(Exception):
+ pass
+
+
+def parse_environment_marker(marker):
+ """Parse a simple marker of <= 1 environment restriction"""
+ marker = marker.strip()
+ if marker.startswith('(') and marker.endswith(')'):
+ marker = marker[1:-1].strip()
+
+ m = COMPLEX_ENV_MARKER_RE.search(marker)
+ if m:
+ raise ComplexEnvironmentMarker()
+
+ m = SIMPLE_ENV_MARKER_RE.match(marker)
+ if not m:
+ raise ComplexEnvironmentMarker()
+
+ return (
+ m.group('marker'),
+ m.group('op'),
+ m.group('value'),
+ )
diff --git a/dhpython/option.py b/dhpython/option.py
new file mode 100644
index 0000000..7d70dbe
--- /dev/null
+++ b/dhpython/option.py
@@ -0,0 +1,30 @@
+# -*- coding: UTF-8 -*-
+# Copyright © 2010-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 re
+
+
+def compiled_regex(string):
+ """argparse regex type"""
+ try:
+ return re.compile(string)
+ except re.error:
+ raise ValueError("regular expression is not valid")
diff --git a/dhpython/pydist.py b/dhpython/pydist.py
new file mode 100644
index 0000000..015b5f2
--- /dev/null
+++ b/dhpython/pydist.py
@@ -0,0 +1,692 @@
+# Copyright © 2010-2020 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 email
+import logging
+import platform
+import os
+import re
+from functools import partial
+from os.path import exists, isdir, join
+from subprocess import PIPE, Popen
+
+if __name__ == '__main__':
+ import sys
+ sys.path.append(os.path.abspath(join(os.path.dirname(__file__), '..')))
+
+from dhpython import PKG_PREFIX_MAP, PUBLIC_DIR_RE,\
+ PYDIST_DIRS, PYDIST_OVERRIDES_FNAMES, PYDIST_DPKG_SEARCH_TPLS
+from dhpython.markers import ComplexEnvironmentMarker, parse_environment_marker
+from dhpython.tools import memoize
+from dhpython.version import get_requested_versions, Version
+
+log = logging.getLogger('dhpython')
+
+PYDIST_RE = re.compile(r"""
+ (?P<name>[A-Za-z][A-Za-z0-9_.-]*) # Python distribution name
+ \s*
+ (?P<vrange>(?:-?\d\.\d+(?:-(?:\d\.\d+)?)?)?) # version range
+ \s*
+ (?P<dependency>(?:[a-z][^;]*)?) # Debian dependency
+ (?: # optional upstream version -> Debian version translator
+ ;\s*
+ (?P<standard>PEP386)? # PEP-386 mode
+ \s*
+ (?P<rules>(?:s|tr|y).*)? # translator rules
+ )?
+ """, re.VERBOSE)
+REQUIRES_RE = re.compile(r'''
+ (?P<name>[A-Za-z][A-Za-z0-9_.-]*) # Python distribution name
+ \s*
+ (?P<enabled_extras>(?:\[[^\]]*\])?) # ignored for now
+ \s*
+ \(? # optional parenthesis
+ (?: # optional minimum/maximum version
+ (?P<operator><=?|>=?|==|!=|~=)
+ \s*
+ (?P<version>(\w|[-.*])+)
+ (?: # optional interval minimum/maximum version
+ \s*
+ ,
+ \s*
+ (?P<operator2><=?|>=?|==|!=)
+ \s*
+ (?P<version2>(\w|[-.])+)
+ )?
+ )?
+ \)? # optional closing parenthesis
+ \s*
+ (?:; # optional environment markers
+ (?P<environment_marker>.+)
+ )?
+ ''', re.VERBOSE)
+EXTRA_RE = re.compile(r'''
+ ;
+ \s*
+ extra
+ \s*
+ ==
+ \s*
+ (?P<quote>['"])
+ (?P<section>[a-zA-Z0-9-_.]+)
+ (?P=quote)
+ ''', re.VERBOSE)
+REQ_SECTIONS_RE = re.compile(r'''
+ ^
+ \[
+ (?P<section>[a-zA-Z0-9-_.]+)?
+ \s*
+ (?::
+ (?P<environment_marker>.+)
+ )?
+ \]
+ \s*
+ $
+ ''', re.VERBOSE)
+DEB_VERS_OPS = {
+ '==': '=',
+ '<': '<<',
+ '>': '>>',
+ '~=': '>=',
+}
+
+
+def validate(fpath):
+ """Check if pydist file looks good."""
+ with open(fpath, encoding='utf-8') as fp:
+ for line in fp:
+ line = line.strip()
+ if line.startswith('#') or not line:
+ continue
+ if not PYDIST_RE.match(line):
+ log.error('invalid pydist data in file %s: %s',
+ fpath.rsplit('/', 1)[-1], line)
+ return False
+ return True
+
+
+@memoize
+def load(impl):
+ """Load information about installed Python distributions.
+
+ :param impl: interpreter implementation, f.e. cpython2, cpython3, pypy
+ :type impl: str
+ """
+ fname = PYDIST_OVERRIDES_FNAMES.get(impl)
+ if exists(fname):
+ to_check = [fname] # first one!
+ else:
+ to_check = []
+
+ dname = PYDIST_DIRS.get(impl)
+ if isdir(dname):
+ to_check.extend(join(dname, i) for i in os.listdir(dname))
+
+ fbdir = os.environ.get('DH_PYTHON_DIST', '/usr/share/dh-python/dist/')
+ fbname = join(fbdir, '{}_fallback'.format(impl))
+ if exists(fbname): # fall back generated at dh-python build time
+ to_check.append(fbname) # last one!
+
+ result = {}
+ for fpath in to_check:
+ with open(fpath, encoding='utf-8') as fp:
+ for line in fp:
+ line = line.strip()
+ if line.startswith('#') or not line:
+ continue
+ dist = PYDIST_RE.search(line)
+ if not dist:
+ raise Exception('invalid pydist line: %s (in %s)' % (line, fpath))
+ dist = dist.groupdict()
+ name = safe_name(dist['name'])
+ dist['versions'] = get_requested_versions(impl, dist['vrange'])
+ dist['dependency'] = dist['dependency'].strip()
+ if dist['rules']:
+ dist['rules'] = dist['rules'].split(';')
+ else:
+ dist['rules'] = []
+ result.setdefault(name, []).append(dist)
+ return result
+
+
+def guess_dependency(impl, req, version=None, bdep=None,
+ accept_upstream_versions=False):
+ bdep = bdep or {}
+ log.debug('trying to find dependency for %s (python=%s)',
+ req, version)
+ if isinstance(version, str):
+ version = Version(version)
+
+ # some upstreams have weird ideas for distribution name...
+ name, rest = re.compile('([^!><=~ \(\)\[;]+)(.*)').match(req).groups()
+ # TODO: check stdlib and dist-packaged for name.py and name.so files
+ req = safe_name(name) + rest
+
+ data = load(impl)
+ req_d = REQUIRES_RE.match(req)
+ if not req_d:
+ log.info('please ask dh_python3 author to fix REQUIRES_RE '
+ 'or your upstream author to fix requires.txt')
+ raise Exception('requirement is not valid: %s' % req)
+ req_d = req_d.groupdict()
+
+ env_marker_alts = ''
+ if req_d['environment_marker']:
+ action = check_environment_marker_restrictions(
+ req,
+ req_d['environment_marker'],
+ impl)
+ if action is False:
+ return
+ elif action is True:
+ pass
+ else:
+ env_marker_alts = ' ' + action
+
+ name = req_d['name']
+ details = data.get(name.lower())
+ if details:
+ log.debug("dependency: module seems to be installed")
+ for item in details:
+ if version and version not in item.get('versions', version):
+ # rule doesn't match version, try next one
+ continue
+ if not item['dependency']:
+ log.debug("dependency: requirement ignored")
+ return # this requirement should be ignored
+ if item['dependency'].endswith(')'):
+ # no need to translate versions if version is hardcoded in
+ # Debian dependency
+ log.debug("dependency: requirement already has hardcoded version")
+ return item['dependency'] + env_marker_alts
+ if req_d['operator'] == '==' and req_d['version'].endswith('*'):
+ # Translate "== 1.*" to "~= 1.0"
+ req_d['operator'] = '~='
+ req_d['version'] = req_d['version'].replace('*', '0')
+ log.debug("dependency: translated wildcard version to semver limit")
+ if req_d['version'] and (item['standard'] or item['rules']) and\
+ req_d['operator'] not in (None, '!='):
+ o = _translate_op(req_d['operator'])
+ v = _translate(req_d['version'], item['rules'], item['standard'])
+ d = "%s (%s %s)%s" % (
+ item['dependency'], o, v, env_marker_alts)
+ if req_d['version2'] and req_d['operator2'] not in (None,'!='):
+ o2 = _translate_op(req_d['operator2'])
+ v2 = _translate(req_d['version2'], item['rules'], item['standard'])
+ d += ", %s (%s %s)%s" % (
+ item['dependency'], o2, v2, env_marker_alts)
+ elif req_d['operator'] == '~=':
+ o2 = '<<'
+ v2 = _translate(_max_compatible(req_d['version']), item['rules'], item['standard'])
+ d += ", %s (%s %s)%s" % (
+ item['dependency'], o2, v2, env_marker_alts)
+ log.debug("dependency: constructed version")
+ return d
+ elif accept_upstream_versions and req_d['version'] and \
+ req_d['operator'] not in (None,'!='):
+ o = _translate_op(req_d['operator'])
+ d = "%s (%s %s)%s" % (
+ item['dependency'], o, req_d['version'], env_marker_alts)
+ if req_d['version2'] and req_d['operator2'] not in (None,'!='):
+ o2 = _translate_op(req_d['operator2'])
+ d += ", %s (%s %s)%s" % (
+ item['dependency'], o2, req_d['version2'],
+ env_marker_alts)
+ elif req_d['operator'] == '~=':
+ o2 = '<<'
+ d += ", %s (%s %s)%s" % (
+ item['dependency'], o2,
+ _max_compatible(req_d['version']), env_marker_alts)
+ log.debug("dependency: constructed upstream version")
+ return d
+ else:
+ if item['dependency'] in bdep:
+ if None in bdep[item['dependency']] and bdep[item['dependency']][None]:
+ log.debug("dependency: included in build-deps with limits ")
+ return "{} ({}){}".format(
+ item['dependency'], bdep[item['dependency']][None],
+ env_marker_alts)
+ # if arch in bdep[item['dependency']]:
+ # TODO: handle architecture specific dependencies from build depends
+ # (current architecture is needed here)
+ log.debug("dependency: included in build-deps")
+ return item['dependency'] + env_marker_alts
+
+ # search for Egg metadata file or directory (using dpkg -S)
+ dpkg_query_tpl, regex_filter = PYDIST_DPKG_SEARCH_TPLS[impl]
+ dpkg_query = dpkg_query_tpl.format(ci_regexp(safe_name(name)))
+
+ log.debug("invoking dpkg -S %s", dpkg_query)
+ process = Popen(('/usr/bin/dpkg', '-S', dpkg_query),
+ stdout=PIPE, stderr=PIPE)
+ stdout, stderr = process.communicate()
+ if process.returncode == 0:
+ result = set()
+ stdout = str(stdout, 'utf-8')
+ for line in stdout.split('\n'):
+ if not line.strip():
+ continue
+ pkg, path = line.split(':', 1)
+ if regex_filter and not re.search(regex_filter, path):
+ continue
+ result.add(pkg)
+ if len(result) > 1:
+ log.error('more than one package name found for %s dist', name)
+ elif not result:
+ log.debug('dpkg -S did not find package for %s', name)
+ else:
+ log.debug('dependency: found a result with dpkg -S')
+ return result.pop() + env_marker_alts
+ else:
+ log.debug('dpkg -S did not find package for %s: %s', name, stderr)
+
+ pname = sensible_pname(impl, name)
+ log.info('Cannot find package that provides %s. '
+ 'Please add package that provides it to Build-Depends or '
+ 'add "%s %s" line to %s or add proper '
+ 'dependency to Depends by hand and ignore this info.',
+ name, safe_name(name), pname, PYDIST_OVERRIDES_FNAMES[impl])
+ # return pname
+
+
+def check_environment_marker_restrictions(req, marker_str, impl):
+ """Check wither we should include or skip a dependency based on its
+ environment markers.
+
+ Returns: True - to keep a dependency
+ False - to skip it
+ str - to append "| foo" to generated dependencies
+ """
+ if impl != 'cpython3':
+ log.info('Ignoring environment markers for non-Python 3.x: %s', req)
+ return False
+
+ try:
+ marker, op, value = parse_environment_marker(marker_str)
+ except ComplexEnvironmentMarker:
+ log.info('Ignoring complex environment marker: %s', req)
+ return False
+
+ # TODO: Use dynamic values when building arch-dependent
+ # binaries, otherwise static values
+ # TODO: Hurd values?
+ supported_values = {
+ 'implementation_name': ('cpython', 'pypy'),
+ 'os_name': ('posix',),
+ 'platform_system': ('GNU/kFreeBSD', 'Linux'),
+ 'platform_machine': (platform.machine(),),
+ 'platform_python_implementation': ('CPython', 'PyPy'),
+ 'sys_platform': (
+ 'gnukfreebsd8', 'gnukfreebsd9', 'gnukfreebsd10',
+ 'gnukfreebsd11', 'gnukfreebsd12', 'gnukfreebsd13',
+ 'linux'),
+ }
+ if marker in supported_values:
+ sv = supported_values[marker]
+ if op in ('==', '!='):
+ if ((op == '==' and value not in sv)
+ or (op == '!=' and value in sv)):
+ log.debug('Skipping requirement (%s != %s): %s',
+ value, sv, req)
+ return False
+ else:
+ log.info(
+ 'Skipping requirement with unhandled environment marker '
+ 'comparison: %s', req)
+ return False
+
+ elif marker in ('python_version', 'python_full_version',
+ 'implementation_version'):
+ # TODO: Replace with full PEP-440 parser
+ env_ver = value
+ split_ver = value.split('.')
+ if marker == 'python_version':
+ version_parts = 2
+ elif marker == 'python_full_version':
+ version_parts = 3
+ else:
+ version_parts = len(split_ver)
+
+ if '*' in env_ver:
+ if split_ver.index('*') != len(split_ver) -1:
+ log.info('Skipping requirement with intermediate wildcard: %s',
+ req)
+ return False
+ split_ver.pop()
+ env_ver = '.'.join(split_ver)
+ if op == '==':
+ if marker == 'python_full_version':
+ marker = 'python_version'
+ version_parts = 2
+ else:
+ op == '=~'
+ elif op == '!=':
+ if marker == 'python_full_version':
+ marker = 'python_version'
+ version_parts = 2
+ else:
+ log.info('Ignoring wildcard != requirement, not '
+ 'representable in Debian: %s', req)
+ return True
+ else:
+ log.info('Skipping requirement with %s on a wildcard: %s',
+ op, req)
+ return False
+
+ int_ver = []
+ for ver_part in split_ver:
+ if ver_part.isdigit():
+ int_ver.append(int(ver_part))
+ else:
+ env_ver = '.'.join(str(x) for x in int_ver)
+ log.info('Truncating unparseable version %s to %s in %s',
+ value, env_ver, req)
+ break
+
+ if len(int_ver) < version_parts:
+ int_ver.append(0)
+ env_ver += '.0'
+ next_ver = int_ver.copy()
+ next_ver[version_parts - 1] += 1
+ next_ver = '.'.join(str(x) for x in next_ver)
+ prev_ver = int_ver.copy()
+ prev_ver[version_parts - 1] -= 1
+ prev_ver = '.'.join(str(x) for x in prev_ver)
+
+ if op == '<':
+ if int_ver <= [3, 0, 0]:
+ return False
+ return '| python3 (>> {})'.format(env_ver)
+ elif op == '<=':
+ return '| python3 (>> {})'.format(next_ver)
+ elif op == '>=':
+ if int_ver < [3, 0, 0]:
+ return True
+ return '| python3 (<< {})'.format(env_ver)
+ elif op == '>':
+ if int_ver < [3, 0, 0]:
+ return True
+ return '| python3 (<< {})'.format(next_ver)
+ elif op in ('==', '==='):
+ # === is arbitrary equality (PEP 440)
+ if marker == 'python_version' or op == '==':
+ return '| python3 (<< {}) | python3 (>> {})'.format(
+ env_ver, next_ver)
+ else:
+ log.info(
+ 'Skipping requirement with %s environment marker, cannot '
+ 'model in Debian deps: %s', op, req)
+ return False
+ elif op == '~=': # Compatible equality (PEP 440)
+ ceq_next_ver = int_ver[:2]
+ ceq_next_ver[1] += 1
+ ceq_next_ver = '.'.join(str(x) for x in ceq_next_ver)
+ return '| python3 (<< {}) | python3 (>> {})'.format(
+ env_ver, ceq_next_ver)
+ elif op == '!=':
+ log.info('Ignoring != comparison in environment marker, cannot '
+ 'model in Debian deps: %s', req)
+ return True
+
+ elif marker == 'extra':
+ # Handled in section logic of parse_requires_dist()
+ return True
+ else:
+ log.info('Skipping requirement with unknown environment marker: %s',
+ marker)
+ return False
+ return True
+
+
+def parse_pydep(impl, fname, bdep=None, options=None,
+ depends_sec=None, recommends_sec=None, suggests_sec=None):
+ depends_sec = depends_sec or []
+ recommends_sec = recommends_sec or []
+ suggests_sec = suggests_sec or []
+
+ public_dir = PUBLIC_DIR_RE[impl].match(fname)
+ ver = None
+ if public_dir and public_dir.groups() and len(public_dir.group(1)) != 1:
+ ver = public_dir.group(1)
+
+ guess_deps = partial(guess_dependency, impl=impl, version=ver, bdep=bdep,
+ accept_upstream_versions=getattr(
+ options, 'accept_upstream_versions', False))
+
+ result = {'depends': [], 'recommends': [], 'suggests': []}
+ modified = section = False
+ env_action = True
+ processed = []
+ with open(fname, 'r', encoding='utf-8') as fp:
+ for line in fp:
+ line = line.strip()
+ if not line or line.startswith('#'):
+ processed.append(line)
+ continue
+ if line.startswith('['):
+ m = REQ_SECTIONS_RE.match(line)
+ if not m:
+ log.info('Skipping section %s, unable to parse header',
+ line)
+ processed.append(line)
+ section = object()
+ continue
+ section = m.group('section')
+ env_action = True
+ if m.group('environment_marker'):
+ env_action = check_environment_marker_restrictions(
+ line,
+ m.group('environment_marker'),
+ impl)
+ processed.append(line)
+ continue
+ if section:
+ if section in depends_sec:
+ result_key = 'depends'
+ elif section in recommends_sec:
+ result_key = 'recommends'
+ elif section in suggests_sec:
+ result_key = 'suggests'
+ else:
+ processed.append(line)
+ continue
+ else:
+ result_key = 'depends'
+
+ dependency = None
+ if env_action:
+ dependency = guess_deps(req=line)
+ if dependency and isinstance(env_action, str):
+ dependency = ', '.join(
+ part.strip() + ' ' + env_action
+ for part in dependency.split(','))
+
+ if dependency:
+ result[result_key].append(dependency)
+ modified = True
+ else:
+ processed.append(line)
+ if modified and public_dir:
+ with open(fname, 'w', encoding='utf-8') as fp:
+ fp.writelines(i + '\n' for i in processed)
+ return result
+
+
+def parse_requires_dist(impl, fname, bdep=None, options=None, depends_sec=None,
+ recommends_sec=None, suggests_sec=None):
+ """Extract dependencies from a dist-info/METADATA file"""
+ depends_sec = depends_sec or []
+ recommends_sec = recommends_sec or []
+ suggests_sec = suggests_sec or []
+
+ public_dir = PUBLIC_DIR_RE[impl].match(fname)
+ ver = None
+ if public_dir and public_dir.groups() and len(public_dir.group(1)) != 1:
+ ver = public_dir.group(1)
+
+ guess_deps = partial(guess_dependency, impl=impl, version=ver, bdep=bdep,
+ accept_upstream_versions=getattr(
+ options, 'accept_upstream_versions', False))
+ result = {'depends': [], 'recommends': [], 'suggests': []}
+ section = None
+ with open(fname, 'r', encoding='utf-8') as fp:
+ metadata = email.message_from_string(fp.read())
+ requires = metadata.get_all('Requires-Dist', [])
+ for req in requires:
+ m = EXTRA_RE.search(req)
+ result_key = 'depends'
+ if m:
+ section = m.group('section')
+ if section:
+ if section in depends_sec:
+ result_key = 'depends'
+ elif section in recommends_sec:
+ result_key = 'recommends'
+ elif section in suggests_sec:
+ result_key = 'suggests'
+ else:
+ continue
+ dependency = guess_deps(req=req)
+ if dependency:
+ result[result_key].append(dependency)
+ return result
+
+
+def safe_name(name):
+ """Emulate distribute's safe_name."""
+ return re.compile('[^A-Za-z0-9.]+').sub('_', name).lower()
+
+
+def sensible_pname(impl, egg_name):
+ """Guess Debian package name from Egg name."""
+ egg_name = safe_name(egg_name).replace('_', '-')
+ if egg_name.startswith('python-'):
+ egg_name = egg_name[7:]
+ return '{}-{}'.format(PKG_PREFIX_MAP[impl], egg_name.lower())
+
+
+def ci_regexp(name):
+ """Return case insensitive dpkg -S regexp."""
+ return ''.join("[%s%s]" % (i.upper(), i) if i.isalpha() else i for i in name.lower())
+
+
+PRE_VER_RE = re.compile(r'[-.]?(alpha|beta|rc|dev|a|b|c)')
+GROUP_RE = re.compile(r'\$(\d+)')
+
+
+def _pl2py(pattern):
+ """Convert Perl RE patterns used in uscan to Python's
+
+ >>> print(_pl2py('foo$3'))
+ foo\g<3>
+ """
+ return GROUP_RE.sub(r'\\g<\1>', pattern)
+
+
+def _max_compatible(version):
+ """Return the maximum version compatible with `version` in PEP440 terms,
+ used by ~= requires version specifiers.
+
+ https://www.python.org/dev/peps/pep-0440/#compatible-release
+
+ >>> _max_compatible('2.2')
+ '3'
+ >>> _max_compatible('1.4.5')
+ '1.5'
+ >>> _max_compatible('1.3.alpha4')
+ '2'
+ >>> _max_compatible('2.1.3.post5')
+ '2.2'
+
+ """
+ v = Version(version)
+ v.serial = None
+ v.releaselevel = None
+ if v.micro is not None:
+ v.micro = None
+ return str(v + 1)
+ v.minor = None
+ return str(v + 1)
+
+
+def _translate(version, rules, standard):
+ """Translate Python version into Debian one.
+
+ >>> _translate('1.C2betac', ['s/c//gi'], None)
+ '1.2beta'
+ >>> _translate('5-fooa1.2beta3-fooD',
+ ... ['s/^/1:/', 's/-foo//g', 's:([A-Z]):+$1:'], 'PEP386')
+ '1:5~a1.2~beta3+D'
+ >>> _translate('x.y.x.z', ['tr/xy/ab/', 'y,z,Z,'], None)
+ 'a.b.a.Z'
+ """
+ for rule in rules:
+ # uscan supports s, tr and y operations
+ if rule.startswith(('tr', 'y')):
+ # Note: no support for escaped separator in the pattern
+ pos = 1 if rule.startswith('y') else 2
+ tmp = rule[pos + 1:].split(rule[pos])
+ version = version.translate(str.maketrans(tmp[0], tmp[1]))
+ elif rule.startswith('s'):
+ # uscan supports: g, u and x flags
+ tmp = rule[2:].split(rule[1])
+ pattern = re.compile(tmp[0])
+ count = 1
+ if tmp[2:]:
+ flags = tmp[2]
+ if 'g' in flags:
+ count = 0
+ if 'i' in flags:
+ pattern = re.compile(tmp[0], re.I)
+ version = pattern.sub(_pl2py(tmp[1]), version, count)
+ else:
+ log.warn('unknown rule ignored: %s', rule)
+ if standard == 'PEP386':
+ version = PRE_VER_RE.sub(r'~\g<1>', version)
+ return version
+
+
+def _translate_op(operator):
+ """Translate Python version operator into Debian one.
+
+ >>> _translate_op('==')
+ '='
+ >>> _translate_op('<')
+ '<<'
+ >>> _translate_op('<=')
+ '<='
+ """
+ return DEB_VERS_OPS.get(operator, operator)
+
+
+if __name__ == '__main__':
+ impl = os.environ.get('IMPL', 'cpython3')
+ for i in sys.argv[1:]:
+ if os.path.isfile(i):
+ try:
+ print(', '.join(parse_pydep(impl, i)['depends']))
+ except Exception as err:
+ log.error('%s: cannot guess (%s)', i, err)
+ else:
+ try:
+ print(guess_dependency(impl, i) or '')
+ except Exception as err:
+ log.error('%s: cannot guess (%s)', i, err)
diff --git a/dhpython/tools.py b/dhpython/tools.py
new file mode 100644
index 0000000..512f944
--- /dev/null
+++ b/dhpython/tools.py
@@ -0,0 +1,340 @@
+# -*- coding: UTF-8 -*-
+# Copyright © 2010-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
+import os
+import re
+import locale
+from datetime import datetime
+from glob import glob
+from pickle import dumps
+from shutil import rmtree
+from os.path import exists, getsize, isdir, islink, join, split
+from subprocess import Popen, PIPE
+
+log = logging.getLogger('dhpython')
+EGGnPTH_RE = re.compile(r'(.*?)(-py\d\.\d(?:-[^.]*)?)?(\.egg-info|\.pth)$')
+SHAREDLIB_RE = re.compile(r'NEEDED.*libpython(\d\.\d)')
+
+
+def relpath(target, link):
+ """Return relative path.
+
+ >>> relpath('/usr/share/python-foo/foo.py', '/usr/bin/foo', )
+ '../share/python-foo/foo.py'
+ """
+ t = target.split('/')
+ l = link.split('/')
+ while l and l[0] == t[0]:
+ del l[0], t[0]
+ return '/'.join(['..'] * (len(l) - 1) + t)
+
+
+def relative_symlink(target, link):
+ """Create relative symlink."""
+ return os.symlink(relpath(target, link), link)
+
+
+def move_file(fpath, dstdir):
+ """Move file to dstdir. Works with symlinks (including relative ones)."""
+ if isdir(fpath):
+ dname = split(fpath)[-1]
+ for fn in os.listdir(fpath):
+ move_file(join(fpath, fn), join(dstdir, dname))
+
+ if islink(fpath):
+ dstpath = join(dstdir, split(fpath)[-1])
+ relative_symlink(os.readlink(fpath), dstpath)
+ os.remove(fpath)
+ else:
+ os.rename(fpath, dstdir)
+
+
+def move_matching_files(src, dst, pattern, sub=None, repl=''):
+ """Move files (preserving path) that match given pattern.
+
+ move_matching_files('foo/bar/', 'foo/baz/', 'spam/.*\.so$')
+ will move foo/bar/a/b/c/spam/file.so to foo/baz/a/b/c/spam/file.so
+
+ :param sub: regular expression for path part that will be replaced with `repl`
+ :param repl: replacement for `sub`
+ """
+ match = re.compile(pattern).search
+ if sub:
+ sub = re.compile(sub).sub
+ repl = repl or ''
+ for root, dirs, filenames in os.walk(src):
+ for fn in filenames:
+ spath = join(root, fn)
+ if match(spath):
+ if sub is not None:
+ spath = sub(repl, spath)
+ dpath = join(dst, relpath(spath, src))
+ os.renames(spath, dpath)
+
+
+def fix_shebang(fpath, replacement=None):
+ """Normalize file's shebang.
+
+ :param replacement: new shebang command (path to interpreter and options)
+ """
+ try:
+ interpreter = Interpreter.from_file(fpath)
+ except Exception as err:
+ log.debug('fix_shebang (%s): %s', fpath, err)
+ return None
+
+ if not replacement and interpreter.version == '2':
+ # we'll drop /usr/bin/python symlink from python package at some point
+ replacement = '/usr/bin/python2'
+ if interpreter.debug:
+ replacement += '-dbg'
+ elif not replacement and interpreter.path != '/usr/bin/': # f.e. /usr/local/* or */bin/env
+ interpreter.path = '/usr/bin'
+ replacement = repr(interpreter)
+ if replacement:
+ log.info('replacing shebang in %s', fpath)
+ try:
+ with open(fpath, 'rb') as fp:
+ fcontent = fp.readlines()
+ except IOError:
+ log.error('cannot open %s', fpath)
+ return False
+ # do not catch IOError here, the file is zeroed at this stage so it's
+ # better to fail
+ with open(fpath, 'wb') as fp:
+ fp.write(("#! %s\n" % replacement).encode('utf-8'))
+ fp.writelines(fcontent[1:])
+ return True
+
+
+def so2pyver(fpath):
+ """Return libpython version file is linked to or None.
+
+ :rtype: tuple
+ :returns: Python version
+ """
+
+ cmd = "readelf -Wd '%s'" % fpath
+ process = Popen(cmd, stdout=PIPE, shell=True)
+ encoding = locale.getdefaultlocale()[1] or 'utf-8'
+ match = SHAREDLIB_RE.search(str(process.stdout.read(), encoding=encoding))
+ if match:
+ return Version(match.groups()[0])
+
+
+def clean_egg_name(name):
+ """Remove Python version and platform name from Egg files/dirs.
+
+ >>> clean_egg_name('python_pipeline-0.1.3_py3k-py3.1.egg-info')
+ 'python_pipeline-0.1.3_py3k.egg-info'
+ >>> clean_egg_name('Foo-1.2-py2.7-linux-x86_64.egg-info')
+ 'Foo-1.2.egg-info'
+ """
+ match = EGGnPTH_RE.match(name)
+ if match and match.group(2) is not None:
+ return ''.join(match.group(1, 3))
+ return name
+
+
+def parse_ns(fpaths, other=None):
+ """Parse namespace_packages.txt files."""
+ result = set(other or [])
+ for fpath in fpaths:
+ with open(fpath, 'r', encoding='utf-8') as fp:
+ for line in fp:
+ if line:
+ result.add(line.strip())
+ return result
+
+
+def remove_ns(interpreter, package, namespaces, versions):
+ """Remove empty __init__.py files for requested namespaces."""
+ if not isinstance(namespaces, set):
+ namespaces = set(namespaces)
+ keep = set()
+ for ns in namespaces:
+ for version in versions:
+ fpath = join(interpreter.sitedir(package, version), *ns.split('.'))
+ fpath = join(fpath, '__init__.py')
+ if not exists(fpath):
+ continue
+ if getsize(fpath) != 0:
+ log.warning('file not empty, cannot share %s namespace', ns)
+ keep.add(ns)
+ break
+
+ # return a set of namespaces that should be handled by pycompile/pyclean
+ result = namespaces - keep
+
+ # remove empty __init__.py files, if available
+ for ns in result:
+ for version in versions:
+ dpath = join(interpreter.sitedir(package, version), *ns.split('.'))
+ fpath = join(dpath, '__init__.py')
+ if exists(fpath):
+ os.remove(fpath)
+ if not os.listdir(dpath):
+ os.rmdir(dpath)
+ # clean pyshared dir as well
+ dpath = join('debian', package, 'usr/share/pyshared', *ns.split('.'))
+ fpath = join(dpath, '__init__.py')
+ if exists(fpath):
+ os.remove(fpath)
+ if not os.listdir(dpath):
+ os.rmdir(dpath)
+ return result
+
+
+def execute(command, cwd=None, env=None, log_output=None, shell=True):
+ """Execute external shell command.
+
+ :param cdw: current working directory
+ :param env: environment
+ :param log_output:
+ * opened log file or path to this file, or
+ * None if output should be included in the returned dict, or
+ * False if output should be redirected to stdout/stderr
+ """
+ args = {'shell': shell, 'cwd': cwd, 'env': env}
+ close = False
+ if log_output is False:
+ pass
+ elif log_output is None:
+ args.update(stdout=PIPE, stderr=PIPE)
+ elif log_output:
+ if isinstance(log_output, str):
+ close = True
+ log_output = open(log_output, 'a', encoding='utf-8')
+ log_output.write('\n# command executed on {}'.format(datetime.now().isoformat()))
+ log_output.write('\n$ {}\n'.format(command))
+ log_output.flush()
+ args.update(stdout=log_output, stderr=log_output)
+
+ log.debug('invoking: %s', command)
+ with Popen(command, **args) as process:
+ stdout, stderr = process.communicate()
+ close and log_output.close()
+ return dict(returncode=process.returncode,
+ stdout=stdout and str(stdout, 'utf-8'),
+ stderr=stderr and str(stderr, 'utf-8'))
+
+
+class memoize:
+ def __init__(self, func):
+ self.func = func
+ self.cache = {}
+
+ def __call__(self, *args, **kwargs):
+ key = dumps((args, kwargs))
+ if key not in self.cache:
+ self.cache[key] = self.func(*args, **kwargs)
+ return self.cache[key]
+
+
+def pyinstall(interpreter, package, vrange):
+ """Install local files listed in pkg.pyinstall files as public modules."""
+ srcfpath = "./debian/%s.pyinstall" % package
+ if not exists(srcfpath):
+ return
+ impl = interpreter.impl
+ versions = get_requested_versions(impl, vrange)
+
+ for line in open(srcfpath, encoding='utf-8'):
+ if not line or line.startswith('#'):
+ continue
+ details = INSTALL_RE.match(line)
+ if not details:
+ raise ValueError("unrecognized line: %s" % line)
+ details = details.groupdict()
+ if details['module']:
+ details['module'] = details['module'].replace('.', '/')
+ myvers = versions & get_requested_versions(impl, details['vrange'])
+ if not myvers:
+ log.debug('%s.pyinstall: no matching versions for line %s',
+ package, line)
+ continue
+ files = glob(details['pattern'])
+ if not files:
+ raise ValueError("missing file(s): %s" % details['pattern'])
+ for fpath in files:
+ fpath = fpath.lstrip('/.')
+ if details['module']:
+ dstname = join(details['module'], split(fpath)[1])
+ elif fpath.startswith('debian/'):
+ dstname = fpath[7:]
+ else:
+ dstname = fpath
+ for version in myvers:
+ dstfpath = join(interpreter.sitedir(package, version), dstname)
+ dstdir = split(dstfpath)[0]
+ if not exists(dstdir):
+ os.makedirs(dstdir)
+ if exists(dstfpath):
+ os.remove(dstfpath)
+ os.link(fpath, dstfpath)
+
+
+def pyremove(interpreter, package, vrange):
+ """Remove public modules listed in pkg.pyremove file."""
+ srcfpath = "./debian/%s.pyremove" % package
+ if not exists(srcfpath):
+ return
+ impl = interpreter.impl
+ versions = get_requested_versions(impl, vrange)
+
+ for line in open(srcfpath, encoding='utf-8'):
+ if not line or line.startswith('#'):
+ continue
+ details = REMOVE_RE.match(line)
+ if not details:
+ raise ValueError("unrecognized line: %s: %s" % (package, line))
+ details = details.groupdict()
+ myvers = versions & get_requested_versions(impl, details['vrange'])
+ if not myvers:
+ log.debug('%s.pyremove: no matching versions for line %s',
+ package, line)
+ for version in myvers:
+ site_dirs = interpreter.old_sitedirs(package, version)
+ site_dirs.append(interpreter.sitedir(package, version))
+ for sdir in site_dirs:
+ files = glob(sdir + '/' + details['pattern'])
+ for fpath in files:
+ if isdir(fpath):
+ rmtree(fpath)
+ else:
+ os.remove(fpath)
+
+from dhpython.interpreter import Interpreter
+from dhpython.version import Version, get_requested_versions, RANGE_PATTERN
+INSTALL_RE = re.compile(r"""
+ (?P<pattern>.+?) # file pattern
+ (?:\s+ # optional Python module name:
+ (?P<module>[A-Za-z][A-Za-z0-9_.]*)?
+ )?
+ \s* # optional version range:
+ (?P<vrange>%s)?$
+""" % RANGE_PATTERN, re.VERBOSE)
+REMOVE_RE = re.compile(r"""
+ (?P<pattern>.+?) # file pattern
+ \s* # optional version range:
+ (?P<vrange>%s)?$
+""" % RANGE_PATTERN, re.VERBOSE)
diff --git a/dhpython/version.py b/dhpython/version.py
new file mode 100644
index 0000000..98c16b7
--- /dev/null
+++ b/dhpython/version.py
@@ -0,0 +1,457 @@
+# Copyright © 2010-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
+import re
+from os.path import exists
+
+from dhpython import _defaults
+
+RANGE_PATTERN = r'(-)?(\d\.\d+)(?:(-)(\d\.\d+)?)?'
+RANGE_RE = re.compile(RANGE_PATTERN)
+VERSION_RE = re.compile(r'''
+ (?P<major>\d+)\.?
+ (?P<minor>\d+)?\.?
+ (?P<micro>\d+)?[.\s]?
+ (?P<releaselevel>alpha|beta|candidate|final)?[.\s]?
+ (?P<serial>\d+)?''', re.VERBOSE)
+
+log = logging.getLogger('dhpython')
+Interpreter = None
+
+
+class Version:
+ # TODO: Upgrade to PEP-440
+ def __init__(self, value=None, major=None, minor=None, micro=None,
+ releaselevel=None, serial=None):
+ """Construct a new instance.
+
+ >>> Version(major=0, minor=0, micro=0, releaselevel=0, serial=0)
+ Version('0.0')
+ >>> Version('0.0')
+ Version('0.0')
+ """
+ if isinstance(value, (tuple, list)):
+ value = '.'.join(str(i) for i in value)
+ if isinstance(value, Version):
+ for name in ('major', 'minor', 'micro', 'releaselevel', 'serial'):
+ setattr(self, name, getattr(value, name))
+ return
+ comp = locals()
+ del comp['self']
+ del comp['value']
+ if value:
+ match = VERSION_RE.match(value)
+ for name, value in match.groupdict().items() if match else []:
+ if value is not None and comp[name] is None:
+ comp[name] = value
+ for name, value in comp.items():
+ if name != 'releaselevel' and value is not None:
+ value = int(value)
+ setattr(self, name, value)
+ if self.major is None:
+ raise ValueError('major component is required')
+
+ def __str__(self):
+ """Return major.minor or major string.
+
+ >>> str(Version(major=3, minor=2, micro=1, releaselevel='final', serial=4))
+ '3.2'
+ >>> str(Version(major=2))
+ '2'
+ """
+ result = str(self.major)
+ if self.minor is not None:
+ result += '.{}'.format(self.minor)
+ return result
+
+ def __hash__(self):
+ return hash(repr(self))
+
+ def __repr__(self):
+ """Return full version string.
+
+ >>> repr(Version(major=3, minor=2, micro=1, releaselevel='final', serial=4))
+ "Version('3.2.1.final.4')"
+ >>> repr(Version(major=2))
+ "Version('2')"
+ """
+ result = "Version('{}".format(self)
+ for name in ('micro', 'releaselevel', 'serial'):
+ value = getattr(self, name)
+ if not value:
+ break
+ result += '.{}'.format(value)
+ return result + "')"
+
+ def __add__(self, other):
+ """Return next version.
+
+ >>> Version('3.1') + 1
+ Version('3.2')
+ >>> Version('2') + '1'
+ Version('3')
+ """
+ result = Version(self)
+ if self.minor is None:
+ result.major += int(other)
+ else:
+ result.minor += int(other)
+ return result
+
+ def __sub__(self, other):
+ """Return previous version.
+
+ >>> Version('3.1') - 1
+ Version('3.0')
+ >>> Version('3') - '1'
+ Version('2')
+ """
+ result = Version(self)
+ if self.minor is None:
+ result.major -= int(other)
+ new = result.major
+ else:
+ result.minor -= int(other)
+ new = result.minor
+ if new < 0:
+ raise ValueError('cannot decrease version further')
+ return result
+
+ def __eq__(self, other):
+ try:
+ other = Version(other)
+ except Exception:
+ return False
+ return self.__cmp(other) == 0
+
+ def __lt__(self, other):
+ return self.__cmp(other) < 0
+
+ def __le__(self, other):
+ return self.__cmp(other) <= 0
+
+ def __gt__(self, other):
+ return self.__cmp(other) > 0
+
+ def __ge__(self, other):
+ return self.__cmp(other) >= 0
+
+ def __lshift__(self, other):
+ """Compare major.minor or major only (if minor is not set).
+
+ >>> Version('2.6') << Version('2.7')
+ True
+ >>> Version('2.6') << Version('2.6.6')
+ False
+ >>> Version('3') << Version('2')
+ False
+ >>> Version('3.1') << Version('2')
+ False
+ >>> Version('2') << Version('3.2.1.alpha.3')
+ True
+ """
+ if not isinstance(other, Version):
+ other = Version(other)
+ if self.minor is None or other.minor is None:
+ return self.__cmp(other, ignore='minor') < 0
+ else:
+ return self.__cmp(other, ignore='micro') < 0
+
+ def __rshift__(self, other):
+ """Compare major.minor or major only (if minor is not set).
+
+ >>> Version('2.6') >> Version('2.7')
+ False
+ >>> Version('2.6.7') >> Version('2.6.6')
+ False
+ >>> Version('3') >> Version('2')
+ True
+ >>> Version('3.1') >> Version('2')
+ True
+ >>> Version('2.1') >> Version('3.2.1.alpha.3')
+ False
+ """
+ if not isinstance(other, Version):
+ other = Version(other)
+ if self.minor is None or other.minor is None:
+ return self.__cmp(other, ignore='minor') > 0
+ else:
+ return self.__cmp(other, ignore='micro') > 0
+
+ def __cmp(self, other, ignore=None):
+ if not isinstance(other, Version):
+ other = Version(other)
+ for name in ('major', 'minor', 'micro', 'releaselevel', 'serial'):
+ if name == ignore:
+ break
+ value1 = getattr(self, name) or 0
+ value2 = getattr(other, name) or 0
+ if name == 'releaselevel':
+ rmap = {'alpha': -3, 'beta': -2, 'candidate': -1, 'final': 0}
+ value1 = rmap.get(value1, 0)
+ value2 = rmap.get(value2, 0)
+ if value1 == value2:
+ continue
+ return (value1 > value2) - (value1 < value2)
+ return 0
+
+
+class VersionRange:
+ def __init__(self, value=None, minver=None, maxver=None):
+ if minver:
+ self.minver = Version(minver)
+ else:
+ self.minver = None
+ if maxver:
+ self.maxver = Version(maxver)
+ else:
+ self.maxver = None
+
+ if value:
+ minver, maxver = self.parse(value)
+ if minver and self.minver is None:
+ self.minver = minver
+ if maxver and self.maxver is None:
+ self.maxver = maxver
+
+ def __bool__(self):
+ if self.minver is not None or self.maxver is not None:
+ return True
+ return False
+
+ def __str__(self):
+ """Return version range string from given range.
+
+ >>> str(VersionRange(minver='3.4'))
+ '3.4-'
+ >>> str(VersionRange(minver='3.4', maxver='3.6'))
+ '3.4-3.6'
+ >>> str(VersionRange(minver='3.4', maxver='4.0'))
+ '3.4-4.0'
+ >>> str(VersionRange(maxver='3.7'))
+ '-3.7'
+ >>> str(VersionRange(minver='3.5', maxver='3.5'))
+ '3.5'
+ >>> str(VersionRange())
+ '-'
+ """
+ if self.minver is None is self.maxver:
+ return '-'
+ if self.minver == self.maxver:
+ return str(self.minver)
+ elif self.minver is None:
+ return '-{}'.format(self.maxver)
+ elif self.maxver is None:
+ return '{}-'.format(self.minver)
+ else:
+ return '{}-{}'.format(self.minver, self.maxver)
+
+ def __repr__(self):
+ """Return version range string.
+
+ >>> repr(VersionRange('5.0-'))
+ "VersionRange(minver='5.0')"
+ >>> repr(VersionRange('3.0-3.5'))
+ "VersionRange(minver='3.0', maxver='3.5')"
+ """
+ result = 'VersionRange('
+ if self.minver is not None:
+ result += "minver='{}'".format(self.minver)
+ if self.maxver is not None:
+ result += ", maxver='{}'".format(self.maxver)
+ result = result.replace('(, ', '(')
+ return result + ")"
+
+ @staticmethod
+ def parse(value):
+ """Return minimum and maximum Python version from given range.
+
+ >>> VersionRange.parse('3.0-')
+ (Version('3.0'), None)
+ >>> VersionRange.parse('3.1-3.13')
+ (Version('3.1'), Version('3.13'))
+ >>> VersionRange.parse('3.2-4.0')
+ (Version('3.2'), Version('4.0'))
+ >>> VersionRange.parse('-3.7')
+ (None, Version('3.7'))
+ >>> VersionRange.parse('3.2')
+ (Version('3.2'), Version('3.2'))
+ >>> VersionRange.parse('') == VersionRange.parse('-')
+ True
+ >>> VersionRange.parse('>= 4.0')
+ (Version('4.0'), None)
+ """
+ if value in ('', '-'):
+ return None, None
+
+ match = RANGE_RE.match(value)
+ if not match:
+ try:
+ minv, maxv = VersionRange._parse_pycentral(value)
+ except Exception:
+ raise ValueError("version range is invalid: %s" % value)
+ else:
+ groups = match.groups()
+
+ if list(groups).count(None) == 3: # only one version is allowed
+ minv = Version(groups[1])
+ return minv, minv
+
+ minv = maxv = None
+ if groups[0]: # maximum version only
+ maxv = groups[1]
+ else:
+ minv = groups[1]
+ maxv = groups[3]
+
+ minv = Version(minv) if minv else None
+ maxv = Version(maxv) if maxv else None
+
+ if maxv and minv and minv > maxv:
+ raise ValueError("version range is invalid: %s" % value)
+
+ return minv, maxv
+
+ @staticmethod
+ def _parse_pycentral(value):
+ """Parse X-Python3-Version.
+
+ >>> VersionRange._parse_pycentral('>= 3.10')
+ (Version('3.10'), None)
+ >>> VersionRange._parse_pycentral('<< 4.0')
+ (None, Version('4.0'))
+ >>> VersionRange._parse_pycentral('3.1')
+ (Version('3.1'), Version('3.1'))
+ >>> VersionRange._parse_pycentral('3.1, 3.2')
+ (Version('3.1'), None)
+ """
+
+ minv = maxv = None
+ hardcoded = set()
+
+ for item in value.split(','):
+ item = item.strip()
+
+ match = re.match('>=\s*([\d\.]+)', item)
+ if match:
+ minv = match.group(1)
+ continue
+ match = re.match('<<\s*([\d\.]+)', item)
+ if match:
+ maxv = match.group(1)
+ continue
+ match = re.match('^[\d\.]+$', item)
+ if match:
+ hardcoded.add(match.group(0))
+
+ if len(hardcoded) == 1:
+ ver = hardcoded.pop()
+ return Version(ver), Version(ver)
+
+ if not minv and hardcoded:
+ # yeah, no maxv!
+ minv = sorted(hardcoded)[0]
+
+ return Version(minv) if minv else None, Version(maxv) if maxv else None
+
+
+def default(impl):
+ """Return default interpreter version for given implementation."""
+ if impl not in _defaults.DEFAULT:
+ raise ValueError("interpreter implementation not supported: %r" % impl)
+ ver = _defaults.DEFAULT[impl]
+ return Version(major=ver[0], minor=ver[1])
+
+
+def supported(impl):
+ """Return list of supported interpreter versions for given implementation."""
+ if impl not in _defaults.SUPPORTED:
+ raise ValueError("interpreter implementation not supported: %r" % impl)
+ versions = _defaults.SUPPORTED[impl]
+ return [Version(major=v[0], minor=v[1]) for v in versions]
+
+
+def get_requested_versions(impl, vrange=None, available=None):
+ """Return a set of requested and supported Python versions.
+
+ :param impl: interpreter implementation
+ :param available: if set to `True`, return installed versions only,
+ if set to `False`, return requested versions that are not installed.
+ By default returns all requested versions.
+ :type available: bool
+
+ >>> sorted(get_requested_versions('cpython3', '')) == sorted(supported('cpython3'))
+ True
+ >>> sorted(get_requested_versions('cpython3', '-')) == sorted(supported('cpython3'))
+ True
+ >>> get_requested_versions('cpython3', '>= 5.0')
+ set()
+ """
+ if isinstance(vrange, str):
+ vrange = VersionRange(vrange)
+
+ if not vrange:
+ versions = set(supported(impl))
+ else:
+ minv = Version(major=0, minor=0) if vrange.minver is None else vrange.minver
+ maxv = Version(major=99, minor=99) if vrange.maxver is None else vrange.maxver
+ if minv == maxv:
+ versions = set([minv] if minv in supported(impl) else tuple())
+ else:
+ versions = set(v for v in supported(impl) if minv <= v < maxv)
+
+ if available is not None:
+ # to avoid circular imports
+ global Interpreter
+ if Interpreter is None:
+ from dhpython.interpreter import Interpreter
+ if available:
+ interpreter = Interpreter(impl=impl)
+ versions = set(v for v in versions
+ if exists(interpreter.binary(v)))
+ elif available is False:
+ interpreter = Interpreter(impl=impl)
+ versions = set(v for v in versions
+ if not exists(interpreter.binary(v)))
+
+ return versions
+
+
+def build_sorted(versions, impl='cpython3'):
+ """Return sorted list of versions in a build friendly order.
+
+ i.e. default version, if among versions, is sorted last.
+
+ >>> build_sorted([(2, 6), (3, 4), default('cpython3'), (3, 6), (2, 7)])[-1] == default('cpython3')
+ True
+ >>> build_sorted(('3.2', (3, 0), '3.1'))
+ [Version('3.0'), Version('3.1'), Version('3.2')]
+ """
+ default_ver = default(impl)
+
+ result = sorted(Version(v) for v in versions)
+ try:
+ result.remove(default_ver)
+ except ValueError:
+ pass
+ else:
+ result.append(default_ver)
+ return result