1# Copyright (c) 2020, Riverbank Computing Limited
2# All rights reserved.
3#
4# This copy of PyQt-builder is licensed for use under the terms of the SIP
5# License Agreement.  See the file LICENSE for more details.
6#
7# This copy of PyQt-builder may also used under the terms of the GNU General
8# Public License v2 or v3 as published by the Free Software Foundation which
9# can be found in the files LICENSE-GPL2 and LICENSE-GPL3 included in this
10# package.
11#
12# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
13# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
14# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
15# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
16# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
17# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
18# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
19# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
20# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
21# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
22# POSSIBILITY OF SUCH DAMAGE.
23
24
25import glob
26import os
27import sys
28
29from sipbuild import Bindings, BuildableExecutable, UserException, Option
30
31
32class PyQtBindings(Bindings):
33    """ A base class for all PyQt-based bindings. """
34
35    def apply_nonuser_defaults(self, tool):
36        """ Set default values for non-user options that haven't been set yet.
37        """
38
39        project = self.project
40
41        if self.sip_file is None:
42            # The (not very good) naming convention used by MetaSIP.
43            self.sip_file = os.path.join(self.name, self.name + 'mod.sip')
44
45        super().apply_nonuser_defaults(tool)
46
47        self._update_builder_settings('CONFIG', self.qmake_CONFIG)
48        self._update_builder_settings('QT', self.qmake_QT)
49
50        # Add the sources of any support code.
51        qpy_dir = os.path.join(project.root_dir, 'qpy', self.name)
52        if os.path.isdir(qpy_dir):
53            headers = self._matching_files(os.path.join(qpy_dir, '*.h'))
54            c_sources = self._matching_files(os.path.join(qpy_dir, '*.c'))
55            cpp_sources = self._matching_files(os.path.join(qpy_dir, '*.cpp'))
56
57            sources = c_sources + cpp_sources
58
59            self.headers.extend(headers)
60            self.sources.extend(sources)
61
62            if headers or sources:
63                self.include_dirs.append(qpy_dir)
64
65    def apply_user_defaults(self, tool):
66        """ Set default values for user options that haven't been set yet. """
67
68        # Although tags is not a user option, the default depends on one.
69        if len(self.tags) == 0:
70            project = self.project
71
72            self.tags = ['{}_{}'.format(project.tag_prefix,
73                    project.builder.qt_version_tag)]
74
75        super().apply_user_defaults(tool)
76
77    def get_options(self):
78        """ Return the list of configurable options. """
79
80        options = super().get_options()
81
82        # The list of modifications to make to the CONFIG value in a .pro file.
83        # An element may start with '-' to specify that the value should be
84        # removed.
85        options.append(Option('qmake_CONFIG', option_type=list))
86
87        # The list of modifications to make to the QT value in a .pro file.  An
88        # element may start with '-' to specify that the value should be
89        # removed.
90        options.append(Option('qmake_QT', option_type=list))
91
92        # The list of header files to #include in any internal test program.
93        options.append(Option('test_headers', option_type=list))
94
95        # The statement to execute in any internal test program.
96        options.append(Option('test_statement'))
97
98        return options
99
100    def handle_test_output(self, test_output):
101        """ Handle the output of any external test program and return True if
102        the bindings are buildable.
103        """
104
105        # This default implementation assumes that the output is a list of
106        # disabled features.
107
108        if test_output:
109            self.project.progress(
110                    "Disabled {} bindings features: {}.".format(self.name,
111                            ', '.join(test_output)))
112
113            self.disabled_features.extend(test_output)
114
115        return True
116
117    def is_buildable(self):
118        """ Return True of the bindings are buildable. """
119
120        project = self.project
121
122        test = 'cfgtest_' + self.name
123        test_source = test + '.cpp'
124
125        test_source_path = os.path.join(project.tests_dir, test_source)
126        if os.path.isfile(test_source_path):
127            # There is an external test program that should be run.
128            run_test = True
129        elif self.test_statement:
130            # There is an internal test program that doesn't need to be run.
131            test_source_path = None
132            run_test = False
133        else:
134            # There is no test program so defer to the super-class.
135            return super().is_buildable()
136
137        self.project.progress(
138                "Checking to see if the {0} bindings can be built".format(
139                        self.name))
140
141        # Create a buildable for the test prgram.
142        buildable = BuildableExecutable(project, test, self.name)
143        buildable.builder_settings.extend(self.builder_settings)
144        buildable.debug = self.debug
145        buildable.define_macros.extend(self.define_macros)
146        buildable.include_dirs.extend(self.include_dirs)
147        buildable.libraries.extend(self.libraries)
148        buildable.library_dirs.extend(self.library_dirs)
149
150        if test_source_path is None:
151            # Save the internal test to a file.
152            includes = ['#include <{}>'.format(h) for h in self.test_headers]
153
154            source_text = '''%s
155
156int main(int, char **)
157{
158    %s;
159}
160''' % ('\n'.join(includes), self.test_statement)
161
162            test_source_path = os.path.join(buildable.build_dir, test_source)
163
164            tf = project.open_for_writing(test_source_path)
165            tf.write(source_text)
166            tf.close()
167
168        buildable.sources.append(test_source_path)
169
170        # Build the test program.
171        test_exe = project.builder.build_executable(buildable, fatal=False)
172        if test_exe is None:
173            return False
174
175        # If the test doesn't need to be run then we are done.
176        if not run_test:
177            return True
178
179        # Run the test and capture the output as a list of lines.
180        test_exe = os.path.join(buildable.build_dir, test_exe)
181
182        # Create the output file, first making sure it doesn't exist.  Note
183        # that we don't use a pipe because we may want a copy of the output for
184        # debugging purposes.
185        out_file = os.path.join(buildable.build_dir, test + '.out')
186
187        try:
188            os.remove(out_file)
189        except OSError:
190            pass
191
192        # Make sure the Qt DLLs get picked up.
193        original_path = None
194
195        if sys.platform == 'win32':
196            qt_bin_dir = os.path.dirname(project.builder.qmake)
197            path = os.environ['PATH']
198            path_parts = path.split(os.path.pathsep)
199
200            if qt_bin_dir not in path_parts:
201                original_path = path
202
203                path_parts.insert(0, qt_bin_dir)
204                os.environ['PATH'] = os.pathsep.join(path_parts)
205
206        self.project.run_command([test_exe, out_file], fatal=False)
207
208        if original_path is not None:
209            os.environ['PATH'] = original_path
210
211        if not os.path.isfile(out_file):
212            raise UserException(
213                    "'{0}' didn't create any output".format(test_exe))
214
215        # Read the details.
216        with open(out_file) as f:
217            test_output = f.read().strip()
218
219        test_output = test_output.split('\n') if test_output else []
220
221        return self.handle_test_output(test_output)
222
223    @staticmethod
224    def _matching_files(pattern):
225        """ Return a reproducable list of files that match a pattern. """
226
227        return sorted(glob.glob(pattern))
228
229    def _update_builder_settings(self, name, modifications):
230        """ Update the builder settings with a list of modifications to a
231        value.
232        """
233
234        add = []
235        remove = []
236
237        for mod in modifications:
238            if mod.startswith('-'):
239                remove.append(mod[1:])
240            else:
241                add.append(mod)
242
243        if add:
244            self.builder_settings.append(
245                    '{} += {}'.format(name, ' '.join(add)))
246
247        if remove:
248            self.builder_settings.append(
249                    '{} -= {}'.format(name, ' '.join(remove)))
250