summaryrefslogtreecommitdiff
path: root/scripts/link_pyqt.py
blob: 158cc145d36c34fa17552d83a34811009e934d73 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
#!/usr/bin/env python3
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:

# Copyright 2014-2021 Florian Bruhin (The Compiler) <mail@qutebrowser.org>

# This file is part of qutebrowser.
#
# qutebrowser is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# qutebrowser is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with qutebrowser.  If not, see <https://www.gnu.org/licenses/>.

"""Symlink PyQt into a given virtualenv."""

import os
import os.path
import argparse
import shutil
import sys
import subprocess
import tempfile
import filecmp


class Error(Exception):

    """Exception raised when linking fails."""


def run_py(executable, *code):
    """Run the given python code with the given executable."""
    if os.name == 'nt' and len(code) > 1:
        # Windows can't do newlines in arguments...
        oshandle, filename = tempfile.mkstemp()
        with os.fdopen(oshandle, 'w') as f:
            f.write('\n'.join(code))
        cmd = [executable, filename]
        try:
            ret = subprocess.run(cmd, universal_newlines=True, check=True,
                                 stdout=subprocess.PIPE).stdout
        finally:
            os.remove(filename)
    else:
        cmd = [executable, '-c', '\n'.join(code)]
        ret = subprocess.run(cmd, universal_newlines=True, check=True,
                             stdout=subprocess.PIPE).stdout
    return ret.rstrip()


def verbose_copy(src, dst, *, follow_symlinks=True):
    """Copy function for shutil.copytree which prints copied files."""
    if '-v' in sys.argv:
        print('{} -> {}'.format(src, dst))
    shutil.copy(src, dst, follow_symlinks=follow_symlinks)


def get_ignored_files(directory, files):
    """Get the files which should be ignored for link_pyqt() on Windows."""
    needed_exts = ('.py', '.dll', '.pyd', '.so')
    ignored_dirs = ('examples', 'qml', 'uic', 'doc')
    filtered = []
    for f in files:
        ext = os.path.splitext(f)[1]
        full_path = os.path.join(directory, f)
        if os.path.isdir(full_path) and f in ignored_dirs:
            filtered.append(f)
        elif (ext not in needed_exts) and os.path.isfile(full_path):
            filtered.append(f)
    return filtered


def needs_update(source, dest):
    """Check if a file to be linked/copied needs to be updated."""
    if os.path.islink(dest):
        # No need to delete a link and relink -> skip this
        return False
    elif os.path.isdir(dest):
        diffs = filecmp.dircmp(source, dest)
        ignored = get_ignored_files(source, diffs.left_only)
        has_new_files = set(ignored) != set(diffs.left_only)
        return (has_new_files or diffs.right_only or diffs.common_funny or
                diffs.diff_files or diffs.funny_files)
    else:
        return not filecmp.cmp(source, dest)


def get_lib_path(executable, name, required=True):
    """Get the path of a python library.

    Args:
        executable: The Python executable to use.
        name: The name of the library to get the path for.
        required: Whether Error should be raised if the lib was not found.
    """
    code = [
        'try:',
        '    import {}'.format(name),
        'except ImportError as e:',
        '    print("ImportError: " + str(e))',
        'else:',
        '    print("path: " + {}.__file__)'.format(name)
    ]
    output = run_py(executable, *code)

    try:
        prefix, data = output.split(': ')
    except ValueError:
        raise ValueError("Unexpected output: {!r}".format(output))

    if prefix == 'path':
        return data
    elif prefix == 'ImportError':
        if required:
            raise Error("Could not import {} with {}: {}!".format(
                name, executable, data))
        return None
    else:
        raise ValueError("Unexpected output: {!r}".format(output))


def link_pyqt(executable, venv_path):
    """Symlink the systemwide PyQt/sip into the venv.

    Args:
        executable: The python executable where the source files are present.
        venv_path: The path to the virtualenv site-packages.
    """
    try:
        get_lib_path(executable, 'PyQt5.sip')
    except Error:
        # There is no PyQt5.sip, so we need to copy the toplevel sip.
        sip_file = get_lib_path(executable, 'sip')
    else:
        # There is a PyQt5.sip, it'll get copied with the PyQt5 dir.
        sip_file = None

    sipconfig_file = get_lib_path(executable, 'sipconfig', required=False)
    pyqt_dir = os.path.dirname(get_lib_path(executable, 'PyQt5.QtCore'))

    for path in [sip_file, sipconfig_file, pyqt_dir]:
        if path is None:
            continue

        fn = os.path.basename(path)
        dest = os.path.join(venv_path, fn)

        if os.path.exists(dest):
            if needs_update(path, dest):
                remove(dest)
            else:
                continue

        copy_or_link(path, dest)


def copy_or_link(source, dest):
    """Copy or symlink source to dest."""
    if os.name == 'nt':
        if os.path.isdir(source):
            print('{} -> {}'.format(source, dest))
            shutil.copytree(source, dest, ignore=get_ignored_files,
                            copy_function=verbose_copy)
        else:
            print('{} -> {}'.format(source, dest))
            shutil.copy(source, dest)
    else:
        print('{} -> {}'.format(source, dest))
        os.symlink(source, dest)


def remove(filename):
    """Remove a given filename, regardless of whether it's a file or dir."""
    if os.path.isdir(filename):
        shutil.rmtree(filename)
    else:
        os.unlink(filename)


def get_venv_lib_path(path):
    """Get the library path of a virtualenv."""
    subdir = 'Scripts' if os.name == 'nt' else 'bin'
    executable = os.path.join(path, subdir, 'python')
    return run_py(executable,
                  'from sysconfig import get_path',
                  'print(get_path("platlib"))')


def get_tox_syspython(tox_path):
    """Get the system python based on a virtualenv created by tox."""
    path = os.path.join(tox_path, '.tox-config1')
    with open(path, encoding='ascii') as f:
        line = f.readline()
    _md5, sys_python = line.rstrip().split(' ', 1)
    # Follow symlinks to get the system-wide interpreter if we have a tox isolated
    # build.
    return os.path.realpath(sys_python)


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument('path', help="Base path to the venv.")
    parser.add_argument('--tox', help="Add when called via tox.",
                        action='store_true')
    args = parser.parse_args()

    if args.tox:
        # Workaround for the lack of negative factors in tox.ini
        if 'LINK_PYQT_SKIP' in os.environ:
            print('LINK_PYQT_SKIP set, exiting...')
            sys.exit(0)
        executable = get_tox_syspython(args.path)
    else:
        executable = sys.executable

    venv_path = get_venv_lib_path(args.path)
    link_pyqt(executable, venv_path)


if __name__ == '__main__':
    try:
        main()
    except Error as e:
        print(str(e), file=sys.stderr)
        sys.exit(1)