1
# Copyright (C) 2014 Canonical Ltd.
2
# Author: Colin Watson <cjwatson@ubuntu.com>
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.
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.
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/>.
16
"""Mock function support based on GObject Introspection.
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.)
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:
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?").
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.
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
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
65
It's still not perfect:
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
72
* The subprocess arrangements are unavoidably slow and it's possible that
73
they may cause problems with some test runners.
75
* Some C functions (such as `stat`) tend to have multiple underlying entry
76
points in the C library which must be preloaded independently.
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
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
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.
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`.
99
I think the benefits, in terms of local clarity of tests, are worth the
103
from __future__ import print_function
106
__all__ = ['GIMockTestCase']
112
from functools import partial
119
from textwrap import dedent
123
from unittest import mock
127
import xml.etree.cElementTree as etree
129
import xml.etree.ElementTree as etree
131
from click.tests.gimock_types import Stat, Stat64
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"
141
return '{%s}%s' % (CORE_NS, tag)
145
return '{%s}%s' % (GLIB_NS, tag)
149
return '{%s}%s' % (C_NS, tag)
152
# Override some c:type annotations that g-ir-scanner gets a bit wrong.
154
"passwd*": "struct passwd*",
155
"stat*": "struct stat*",
156
"stat64*": "struct stat64*",
160
# Mapping of GI type name -> ctypes type.
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,
172
"utf8": ctypes.c_char_p,
173
"utf8*": ctypes.POINTER(ctypes.c_char_p),
177
class GIMockTestCase(unittest.TestCase):
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 = {}
187
self._preload_func_refs = []
188
self._composite_refs = []
189
self._delegate_funcs = {}
191
def _gir_get_type(self, obj):
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"))
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":
203
if arrayinfo is not None:
206
ret["c"] = _c_type_override.get(raw_ctype, raw_ctype)
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
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
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_", "__")
226
for attr in func.findall(_corens("attribute")):
227
if attr.get("name") == "headers":
228
headers = attr.get("value")
230
rv = func.find(_corens("return-value"))
231
assert rv is not None
233
paramnode = func.find(_corens("parameters"))
234
if paramnode is not None:
235
for param in paramnode.findall(_corens("parameter")):
237
"name": param.get("name"),
238
"type": self._gir_get_type(param),
240
if func.get("throws", "0") == "1":
243
"type": { "gi": "GError**", "c": "GError**" },
248
"rv": self._gir_get_type(rv),
253
def _ctypes_type(self, gi_type):
254
return _typemap[gi_type["gi"]]
256
def make_preloads(self, preloads):
260
# Not strictly needed, but convenient for ad-hoc debugging.
267
preload_headers = set()
268
funcs = self._parse_gir("click/tests/preload.gir")
269
for name, func in preloads.items():
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)
285
for info, _ in rpreloads:
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.
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;
305
extern void _gimock_init_%(name)s (preloadtype_%(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
314
real_%(name)s = dlsym (RTLD_NEXT, \"%(name)s\");
315
if (dlerror ()) _exit (1);
319
if conv["ret"] == "void":
321
void %(name)s (%(proto)s)
323
if (ctypes_%(name)s) {
324
delegate_%(name)s = 0;
325
(*ctypes_%(name)s) (%(args)s);
326
if (! delegate_%(name)s)
329
(*real_%(name)s) (%(args)s);
334
%(ret)s %(name)s (%(proto)s)
336
if (ctypes_%(name)s) {
338
delegate_%(name)s = 0;
339
ret = (*ctypes_%(name)s) (%(args)s);
340
if (! delegate_%(name)s)
343
return (*real_%(name)s) (%(args)s);
347
extern void _gimock_delegate_%(name)s (void)
349
delegate_%(name)s = 1;
353
static void __attribute__ ((constructor))
354
gimockpreload_init (void)
358
for info, _ in rpreloads:
360
print(" real_%s = dlsym (RTLD_NEXT, \"%s\");" %
361
(name, name), file=c)
362
print(" if (dlerror ()) _exit (1);", file=c)
364
if "GIMOCK_PRELOAD_DEBUG" in os.environ:
365
with open(c_path) as c:
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",
375
"-Wl,-soname", "-Wl,libgimockpreload.so",
376
c_path, "-ldl", "-o", lib_path,
378
return lib_path, rpreloads
381
# with self.run_in_subprocess("func", ...) as enter:
384
@contextlib.contextmanager
385
def run_in_subprocess(self, *patches):
387
for patch in patches:
388
preloads[patch] = mock.MagicMock()
390
lib_path, rpreloads = self.make_preloads(preloads)
392
lib_path, rpreloads = None, None
394
class ParentProcess(Exception):
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)
408
preload_lib, "_gimock_init_%s" % info["name"])
409
cfunc = cfunctype(func)
410
self._preload_func_refs.append(cfunc)
413
preload_lib, "_gimock_delegate_%s" % info["name"])
414
self._delegate_funcs[info["name"]] = delegate_cfunctype(
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)
423
fcntl.fcntl(rfd, fcntl.F_SETFD, fcntl.FD_CLOEXEC)
425
sys.executable, "-m", "unittest",
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)
435
reader = os.fdopen(rfd, "rb")
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!")
443
raise ParentProcess()
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)
453
except ParentProcess:
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.
464
traceback.print_exc()
465
pickle.dump(type(e), writer)
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)
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)
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)
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)
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]()