diff options
Diffstat (limited to '')
-rw-r--r-- | dhpython/tools.py | 340 |
1 files changed, 340 insertions, 0 deletions
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) |