~click-hackers/click/trunk

« back to all changes in this revision

Viewing changes to click/tests/gimock.py

  • Committer: Colin Watson
  • Date: 2014-03-03 17:01:37 UTC
  • mto: This revision was merged to the branch mainline in revision 354.
  • Revision ID: cjwatson@canonical.com-20140303170137-4gs1zjmqtgkvzphq
Move an initial core of functionality (database, hooks, osextras, query,
user) from Python into a new "libclick" library, allowing
performance-critical clients to avoid the cost of starting a new Python
interpreter (LP: #1282311).

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright (C) 2014 Canonical Ltd.
 
2
# Author: Colin Watson <cjwatson@ubuntu.com>
 
3
 
 
4
# This program is free software: you can redistribute it and/or modify
 
5
# it under the terms of the GNU General Public License as published by
 
6
# the Free Software Foundation; version 3 of the License.
 
7
#
 
8
# This program is distributed in the hope that it will be useful,
 
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
 
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
11
# GNU General Public License for more details.
 
12
#
 
13
# You should have received a copy of the GNU General Public License
 
14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
15
 
 
16
"""Mock function support based on GObject Introspection.
 
17
 
 
18
(Note to reviewers: I expect to rewrite this from scratch on my own time as
 
19
a more generalised set of Python modules for unit testing of C code,
 
20
although using similar core ideas.  This is a first draft for the purpose of
 
21
getting Click's test suite to work expediently, rather than an interface I'm
 
22
prepared to commit to long-term.)
 
23
 
 
24
Python is a versatile and concise language for writing tests, and GObject
 
25
Introspection (GI) makes it straightforward (often trivial) to bind native
 
26
code into Python.  However, writing tests for native code quickly runs into
 
27
the problem of how to build mock functions.  You might reasonably have code
 
28
that calls chown(), for instance, and want to test how it's called rather
 
29
than worrying about setting up a fakeroot-type environment where chown()
 
30
will work.  The obvious solution is to use `LD_PRELOAD` wrappers, but there
 
31
are various problems to overcome in practice:
 
32
 
 
33
 * You can only set up a new `LD_PRELOAD` by going through the run-time
 
34
   linker; you can't just set it for a single in-process test case.
 
35
 * Generating the preloaded wrapper involves a fair bit of boilerplate code.
 
36
 * Having to write per-test mock code in C is inconvenient, and makes it
 
37
   difficult to get information back out of the mock (such as "how often was
 
38
   this function called, and with what arguments?").
 
39
 
 
40
The first problem can be solved by a decorator that knows how to run
 
41
individual tests in a subprocess.  This is made somewhat more inconvenient
 
42
by the fact that there is no way for a context manager's `__enter__` method
 
43
to avoid executing the context-managed block other than by throwing an
 
44
exception, which makes it hard to silently avoid executing the test case in
 
45
the parent process, but we can work around this at the cost of an extra line
 
46
of code per invocation.
 
47
 
 
48
For the rest, a combination of GI itself and ctypes can help.  We can use GI
 
49
to keep track of argument and return types of the mocked C functions in a
 
50
reasonably sane way, by parsing header files.  We're operating in the other
 
51
direction from how GI is normally used, so PyGObject can't deal with
 
52
bridging the two calling conventions for us.  ctypes can: but we still need
 
53
to be careful!  We have to construct the callback functions in the child
 
54
process, ensure that we keep references to them, and inject function
 
55
pointers into the preloaded library via specially-named helper functions;
 
56
until those function pointers are set up we must make sure to call the libc
 
57
functions instead (since some of them might be called during Python
 
58
startup).
 
59
 
 
60
The combination of all of this allows us to bridge C functions somewhat
 
61
transparently into Python.  This lets you supply a Python function or method
 
62
as the mock replacement for a C library function, making it much simpler to
 
63
record state.
 
64
 
 
65
It's still not perfect:
 
66
 
 
67
 * We're using GI in an upside-down kind of way, and we specifically need
 
68
   GIR files rather than typelibs so that we can extract the original C
 
69
   type, so some fiddling is required for each new function you want to
 
70
   mock.
 
71
 
 
72
 * The subprocess arrangements are unavoidably slow and it's possible that
 
73
   they may cause problems with some test runners.
 
74
 
 
75
 * Some C functions (such as `stat`) tend to have multiple underlying entry
 
76
   points in the C library which must be preloaded independently.
 
77
 
 
78
 * You have to be careful about how your libraries are linked, because `ld
 
79
   -Wl,-Bsymbolic-functions` prevents `LD_PRELOAD` working for intra-library
 
80
   calls.
 
81
 
 
82
 * `ctypes should return composite types from callbacks
 
83
   <http://bugs.python.org/issue5710>`_.  The least awful approach for now
 
84
   seems to be to construct the composite type in question, stash a
 
85
   reference to it forever, and then return a pointer to it as a void *; we
 
86
   can only get away with this because tests are by nature relatively
 
87
   short-lived.
 
88
 
 
89
 * The ctypes module's handling of 64-bit pointers is basically just awful.
 
90
   The right answer is probably to use a different callback-generation
 
91
   framework entirely (maybe extending PyGObject so that we can get at the
 
92
   pieces we need), but I've hacked around it for now.
 
93
 
 
94
 * It doesn't appear to be possible to install mock replacements for
 
95
   functions that are called directly from Python code using their GI
 
96
   wrappers.  You can work around this by simply patching the GI wrapper
 
97
   instead, using `mock.patch`.
 
98
 
 
99
I think the benefits, in terms of local clarity of tests, are worth the
 
100
downsides.
 
101
"""
 
102
 
 
103
from __future__ import print_function
 
104
 
 
105
__metaclass__ = type
 
106
__all__ = ['GIMockTestCase']
 
107
 
 
108
 
 
109
import contextlib
 
110
import ctypes
 
111
import fcntl
 
112
from functools import partial
 
113
import os
 
114
import pickle
 
115
import shutil
 
116
import subprocess
 
117
import sys
 
118
import tempfile
 
119
from textwrap import dedent
 
120
import traceback
 
121
import unittest
 
122
try:
 
123
    from unittest import mock
 
124
except ImportError:
 
125
    import mock
 
126
try:
 
127
    import xml.etree.cElementTree as etree
 
128
except ImportError:
 
129
    import xml.etree.ElementTree as etree
 
130
 
 
131
from click.tests.gimock_types import Stat, Stat64
 
132
 
 
133
 
 
134
# Borrowed from giscanner.girparser.
 
135
CORE_NS = "http://www.gtk.org/introspection/core/1.0"
 
136
C_NS = "http://www.gtk.org/introspection/c/1.0"
 
137
GLIB_NS = "http://www.gtk.org/introspection/glib/1.0"
 
138
 
 
139
 
 
140
def _corens(tag):
 
141
    return '{%s}%s' % (CORE_NS, tag)
 
142
 
 
143
 
 
144
def _glibns(tag):
 
145
    return '{%s}%s' % (GLIB_NS, tag)
 
146
 
 
147
 
 
148
def _cns(tag):
 
149
    return '{%s}%s' % (C_NS, tag)
 
150
 
 
151
 
 
152
# Override some c:type annotations that g-ir-scanner gets a bit wrong.
 
153
_c_type_override = {
 
154
    "passwd*": "struct passwd*",
 
155
    "stat*": "struct stat*",
 
156
    "stat64*": "struct stat64*",
 
157
    }
 
158
 
 
159
 
 
160
# Mapping of GI type name -> ctypes type.
 
161
_typemap = {
 
162
    "GError**": ctypes.c_void_p,
 
163
    "gboolean": ctypes.c_bool,
 
164
    "gint": ctypes.c_int,
 
165
    "gint*": ctypes.POINTER(ctypes.c_int),
 
166
    "gint32": ctypes.c_int32,
 
167
    "gpointer": ctypes.c_void_p,
 
168
    "guint": ctypes.c_uint,
 
169
    "guint8**": ctypes.POINTER(ctypes.POINTER(ctypes.c_uint8)),
 
170
    "guint32": ctypes.c_uint32,
 
171
    "none": None,
 
172
    "utf8": ctypes.c_char_p,
 
173
    "utf8*": ctypes.POINTER(ctypes.c_char_p),
 
174
    }
 
175
 
 
176
 
 
177
class GIMockTestCase(unittest.TestCase):
 
178
    def setUp(self):
 
179
        super(GIMockTestCase, self).setUp()
 
180
        self._gimock_temp_dir = tempfile.mkdtemp(prefix="gimock")
 
181
        self.addCleanup(shutil.rmtree, self._gimock_temp_dir)
 
182
        self._preload_func_refs = []
 
183
        self._composite_refs = []
 
184
        self._delegate_funcs = {}
 
185
 
 
186
    def tearDown(self):
 
187
        self._preload_func_refs = []
 
188
        self._composite_refs = []
 
189
        self._delegate_funcs = {}
 
190
 
 
191
    def _gir_get_type(self, obj):
 
192
        ret = {}
 
193
        arrayinfo = obj.find(_corens("array"))
 
194
        if arrayinfo is not None:
 
195
            typeinfo = arrayinfo.find(_corens("type"))
 
196
            raw_ctype = arrayinfo.get(_cns("type"))
 
197
        else:
 
198
            typeinfo = obj.find(_corens("type"))
 
199
            raw_ctype = typeinfo.get(_cns("type"))
 
200
        gi_type = typeinfo.get("name")
 
201
        if obj.get("direction", "in") == "out":
 
202
            gi_type += "*"
 
203
        if arrayinfo is not None:
 
204
            gi_type += "*"
 
205
        ret["gi"] = gi_type
 
206
        ret["c"] = _c_type_override.get(raw_ctype, raw_ctype)
 
207
        return ret
 
208
 
 
209
    def _parse_gir(self, path):
 
210
        # A very, very crude GIR parser.  We might have used
 
211
        # giscanner.girparser, but it's not importable in Python 3 at the
 
212
        # moment.
 
213
        tree = etree.parse(path)
 
214
        root = tree.getroot()
 
215
        assert root.tag == _corens("repository")
 
216
        assert root.get("version") == "1.2"
 
217
        ns = root.find(_corens("namespace"))
 
218
        assert ns is not None
 
219
        funcs = {}
 
220
        for func in ns.findall(_corens("function")):
 
221
            name = func.get(_cns("identifier"))
 
222
            # g-ir-scanner skips identifiers starting with "__", which we
 
223
            # need in order to mock stat effectively.  Work around this.
 
224
            name = name.replace("under_under_", "__")
 
225
            headers = None
 
226
            for attr in func.findall(_corens("attribute")):
 
227
                if attr.get("name") == "headers":
 
228
                    headers = attr.get("value")
 
229
                    break
 
230
            rv = func.find(_corens("return-value"))
 
231
            assert rv is not None
 
232
            params = []
 
233
            paramnode = func.find(_corens("parameters"))
 
234
            if paramnode is not None:
 
235
                for param in paramnode.findall(_corens("parameter")):
 
236
                    params.append({
 
237
                        "name": param.get("name"),
 
238
                        "type": self._gir_get_type(param),
 
239
                        })
 
240
            if func.get("throws", "0") == "1":
 
241
                params.append({
 
242
                    "name": "error",
 
243
                    "type": { "gi": "GError**", "c": "GError**" },
 
244
                    })
 
245
            funcs[name] = {
 
246
                "name": name,
 
247
                "headers": headers,
 
248
                "rv": self._gir_get_type(rv),
 
249
                "params": params,
 
250
                }
 
251
        return funcs
 
252
 
 
253
    def _ctypes_type(self, gi_type):
 
254
        return _typemap[gi_type["gi"]]
 
255
 
 
256
    def make_preloads(self, preloads):
 
257
        rpreloads = []
 
258
        std_headers = set([
 
259
            "dlfcn.h",
 
260
            # Not strictly needed, but convenient for ad-hoc debugging.
 
261
            "stdio.h",
 
262
            "stdint.h",
 
263
            "stdlib.h",
 
264
            "sys/types.h",
 
265
            "unistd.h",
 
266
            ])
 
267
        preload_headers = set()
 
268
        funcs = self._parse_gir("click/tests/preload.gir")
 
269
        for name, func in preloads.items():
 
270
            info = funcs[name]
 
271
            rpreloads.append([info, func])
 
272
            headers = info["headers"]
 
273
            if headers is not None:
 
274
                preload_headers.update(headers.split(","))
 
275
        if "GIMOCK_SUBPROCESS" in os.environ:
 
276
            return None, rpreloads
 
277
        preloads_dir = os.path.join(self._gimock_temp_dir, "_preloads")
 
278
        os.makedirs(preloads_dir)
 
279
        c_path = os.path.join(preloads_dir, "gimockpreload.c")
 
280
        with open(c_path, "w") as c:
 
281
            print("#define _GNU_SOURCE", file=c)
 
282
            for header in sorted(std_headers | preload_headers):
 
283
                print("#include <%s>" % header, file=c)
 
284
            print(file=c)
 
285
            for info, _ in rpreloads:
 
286
                conv = {}
 
287
                conv["name"] = info["name"]
 
288
                argtypes = [p["type"]["c"] for p in info["params"]]
 
289
                argnames = [p["name"] for p in info["params"]]
 
290
                conv["ret"] = info["rv"]["c"]
 
291
                conv["bareproto"] = ", ".join(argtypes)
 
292
                conv["proto"] = ", ".join(
 
293
                    "%s %s" % pair for pair in zip(argtypes, argnames))
 
294
                conv["args"] = ", ".join(argnames)
 
295
                # The delegation scheme used here is needed because trying
 
296
                # to pass pointers back and forward through ctypes is a
 
297
                # recipe for having them truncated to 32 bits at the drop of
 
298
                # a hat.  This approach is less obvious but much safer.
 
299
                print(dedent("""\
 
300
                    typedef %(ret)s preloadtype_%(name)s (%(bareproto)s);
 
301
                    preloadtype_%(name)s *ctypes_%(name)s = (void *) 0;
 
302
                    preloadtype_%(name)s *real_%(name)s = (void *) 0;
 
303
                    static volatile int delegate_%(name)s = 0;
 
304
 
 
305
                    extern void _gimock_init_%(name)s (preloadtype_%(name)s *f)
 
306
                    {
 
307
                        ctypes_%(name)s = f;
 
308
                        if (! real_%(name)s) {
 
309
                            /* Retry lookup in case the symbol wasn't
 
310
                             * resolvable until the program under test was
 
311
                             * loaded.
 
312
                             */
 
313
                            dlerror ();
 
314
                            real_%(name)s = dlsym (RTLD_NEXT, \"%(name)s\");
 
315
                            if (dlerror ()) _exit (1);
 
316
                        }
 
317
                    }
 
318
                    """) % conv, file=c)
 
319
                if conv["ret"] == "void":
 
320
                    print(dedent("""\
 
321
                        void %(name)s (%(proto)s)
 
322
                        {
 
323
                            if (ctypes_%(name)s) {
 
324
                                delegate_%(name)s = 0;
 
325
                                (*ctypes_%(name)s) (%(args)s);
 
326
                                if (! delegate_%(name)s)
 
327
                                    return;
 
328
                            }
 
329
                            (*real_%(name)s) (%(args)s);
 
330
                        }
 
331
                        """) % conv, file=c)
 
332
                else:
 
333
                    print(dedent("""\
 
334
                        %(ret)s %(name)s (%(proto)s)
 
335
                        {
 
336
                            if (ctypes_%(name)s) {
 
337
                                %(ret)s ret;
 
338
                                delegate_%(name)s = 0;
 
339
                                ret = (*ctypes_%(name)s) (%(args)s);
 
340
                                if (! delegate_%(name)s)
 
341
                                    return ret;
 
342
                            }
 
343
                            return (*real_%(name)s) (%(args)s);
 
344
                        }
 
345
                        """) % conv, file=c)
 
346
                print(dedent("""\
 
347
                    extern void _gimock_delegate_%(name)s (void)
 
348
                    {
 
349
                        delegate_%(name)s = 1;
 
350
                    }
 
351
                    """) % conv, file=c)
 
352
            print(dedent("""\
 
353
                static void __attribute__ ((constructor))
 
354
                gimockpreload_init (void)
 
355
                {
 
356
                    dlerror ();
 
357
                """), file=c)
 
358
            for info, _ in rpreloads:
 
359
                name = info["name"]
 
360
                print("    real_%s = dlsym (RTLD_NEXT, \"%s\");" %
 
361
                      (name, name), file=c)
 
362
                print("    if (dlerror ()) _exit (1);", file=c)
 
363
            print("}", file=c)
 
364
        if "GIMOCK_PRELOAD_DEBUG" in os.environ:
 
365
            with open(c_path) as c:
 
366
                print(c.read())
 
367
        # TODO: Use libtool or similar rather than hardcoding gcc invocation.
 
368
        lib_path = os.path.join(preloads_dir, "libgimockpreload.so")
 
369
        cflags = subprocess.check_output([
 
370
            "pkg-config", "--cflags", "glib-2.0", "gee-0.8"],
 
371
            universal_newlines=True).rstrip("\n").split()
 
372
        subprocess.check_call([
 
373
            "gcc", "-O0", "-g", "-shared", "-fPIC", "-DPIC", "-I", "lib/click",
 
374
            ] + cflags + [
 
375
            "-Wl,-soname", "-Wl,libgimockpreload.so",
 
376
            c_path, "-ldl", "-o", lib_path,
 
377
            ])
 
378
        return lib_path, rpreloads
 
379
 
 
380
    # Use as:
 
381
    #   with self.run_in_subprocess("func", ...) as enter:
 
382
    #       enter()
 
383
    #       # test case body
 
384
    @contextlib.contextmanager
 
385
    def run_in_subprocess(self, *patches):
 
386
        preloads = {}
 
387
        for patch in patches:
 
388
            preloads[patch] = mock.MagicMock()
 
389
        if preloads:
 
390
            lib_path, rpreloads = self.make_preloads(preloads)
 
391
        else:
 
392
            lib_path, rpreloads = None, None
 
393
 
 
394
        class ParentProcess(Exception):
 
395
            pass
 
396
 
 
397
        def helper(lib_path, rpreloads):
 
398
            if "GIMOCK_SUBPROCESS" in os.environ:
 
399
                del os.environ["LD_PRELOAD"]
 
400
                preload_lib = ctypes.cdll.LoadLibrary(lib_path)
 
401
                delegate_cfunctype = ctypes.CFUNCTYPE(None)
 
402
                for info, func in rpreloads:
 
403
                    signature = [info["rv"]] + [
 
404
                        p["type"] for p in info["params"]]
 
405
                    signature = [self._ctypes_type(t) for t in signature]
 
406
                    cfunctype = ctypes.CFUNCTYPE(*signature)
 
407
                    init = getattr(
 
408
                        preload_lib, "_gimock_init_%s" % info["name"])
 
409
                    cfunc = cfunctype(func)
 
410
                    self._preload_func_refs.append(cfunc)
 
411
                    init(cfunc)
 
412
                    delegate = getattr(
 
413
                        preload_lib, "_gimock_delegate_%s" % info["name"])
 
414
                    self._delegate_funcs[info["name"]] = delegate_cfunctype(
 
415
                        delegate)
 
416
                return
 
417
            rfd, wfd = os.pipe()
 
418
            # It would be cleaner to use subprocess.Popen(pass_fds=[wfd]), but
 
419
            # that isn't available in Python 2.7.
 
420
            if hasattr(os, "set_inheritable"):
 
421
                os.set_inheritable(wfd, True)
 
422
            else:
 
423
                fcntl.fcntl(rfd, fcntl.F_SETFD, fcntl.FD_CLOEXEC)
 
424
            args = [
 
425
                sys.executable, "-m", "unittest",
 
426
                "%s.%s.%s" % (
 
427
                    self.__class__.__module__, self.__class__.__name__,
 
428
                    self._testMethodName)]
 
429
            env = os.environ.copy()
 
430
            env["GIMOCK_SUBPROCESS"] = str(wfd)
 
431
            if lib_path is not None:
 
432
                env["LD_PRELOAD"] = lib_path
 
433
            subp = subprocess.Popen(args, close_fds=False, env=env)
 
434
            os.close(wfd)
 
435
            reader = os.fdopen(rfd, "rb")
 
436
            subp.communicate()
 
437
            exctype = pickle.load(reader)
 
438
            if exctype is not None and issubclass(exctype, AssertionError):
 
439
                raise AssertionError("Subprocess failed a test!")
 
440
            elif exctype is not None or subp.returncode != 0:
 
441
                raise Exception("Subprocess returned an error!")
 
442
            reader.close()
 
443
            raise ParentProcess()
 
444
 
 
445
        try:
 
446
            yield partial(helper, lib_path, rpreloads), preloads
 
447
            if "GIMOCK_SUBPROCESS" in os.environ:
 
448
                wfd = int(os.environ["GIMOCK_SUBPROCESS"])
 
449
                writer = os.fdopen(wfd, "wb")
 
450
                pickle.dump(None, writer)
 
451
                writer.flush()
 
452
                os._exit(0)
 
453
        except ParentProcess:
 
454
            pass
 
455
        except Exception as e:
 
456
            if "GIMOCK_SUBPROCESS" in os.environ:
 
457
                wfd = int(os.environ["GIMOCK_SUBPROCESS"])
 
458
                writer = os.fdopen(wfd, "wb")
 
459
                # It would be better to use tblib to pickle the traceback so
 
460
                # that we can re-raise it properly from the parent process.
 
461
                # Until that's packaged and available to us, just print the
 
462
                # traceback and send the exception type.
 
463
                print()
 
464
                traceback.print_exc()
 
465
                pickle.dump(type(e), writer)
 
466
                writer.flush()
 
467
                os._exit(1)
 
468
            else:
 
469
                raise
 
470
 
 
471
    def make_pointer(self, composite):
 
472
        # Store a reference to a composite type and return a pointer to it,
 
473
        # working around http://bugs.python.org/issue5710.
 
474
        self._composite_refs.append(composite)
 
475
        return ctypes.addressof(composite)
 
476
 
 
477
    def make_string(self, s):
 
478
        # As make_pointer, but for a string.
 
479
        copied = ctypes.create_string_buffer(s.encode())
 
480
        self._composite_refs.append(copied)
 
481
        return ctypes.addressof(copied)
 
482
 
 
483
    def convert_pointer(self, composite_type, address):
 
484
        # Return a ctypes composite type instance at a given address.
 
485
        return composite_type.from_address(address)
 
486
 
 
487
    def convert_stat_pointer(self, name, address):
 
488
        # As convert_pointer, but for a "struct stat *" or "struct stat64 *"
 
489
        # depending on the wrapped function name.
 
490
        stat_type = {"__xstat": Stat, "__xstat64": Stat64}
 
491
        return self.convert_pointer(stat_type[name], address)
 
492
 
 
493
    def delegate_to_original(self, name):
 
494
        # Cause the wrapper function to delegate to the original version
 
495
        # after the callback returns.  (Note that the callback still needs
 
496
        # to return something type-compatible with the declared result type,
 
497
        # although the return value will otherwise be ignored.)
 
498
        self._delegate_funcs[name]()