1
2# Copyright 2008-2017 Jaap Karssenberg <jaap.karssenberg@gmail.com>
3
4'''Zim test suite'''
5
6
7
8
9import os
10import sys
11import tempfile
12import shutil
13import logging
14import gettext
15import xml.etree.cElementTree as etree
16import types
17import glob
18import logging
19
20logger = logging.getLogger('tests')
21
22from functools import partial
23
24
25try:
26	import gi
27	gi.require_version('Gtk', '3.0')
28	from gi.repository import Gtk
29except ImportError:
30	Gtk = None
31
32
33import unittest
34from unittest import skip, skipIf, skipUnless, expectedFailure
35
36
37gettext.install('zim', names=('_', 'gettext', 'ngettext'))
38
39FAST_TEST = False #: determines whether we skip slow tests or not
40FULL_TEST = False #: determine whether we mock filesystem tests or not
41
42# This list also determines the order in which tests will executed
43__all__ = [
44	# Packaging etc.
45	'package', 'translations',
46	# Basic libraries
47	'datetimetz', 'utils', 'errors', 'signals', 'actions',
48	'fs', 'newfs',
49	'config', 'applications',
50	'parsing', 'tokenparser',
51	# Notebook components
52	'formats', 'templates',
53	'indexers', 'indexviews', 'operations', 'notebook', 'history',
54	'export', 'import_files', 'www', 'search',
55	# Core application
56	'widgets', 'pageview', 'save_page', 'clipboard', 'uiactions',
57	'mainwindow', 'notebookdialog',
58	'preferencesdialog', 'searchdialog', 'customtools', 'templateeditordialog',
59	'main', 'plugins',
60	# Plugins
61	'pathbar', 'pageindex', 'toolbar',
62	'journal', 'printtobrowser', 'versioncontrol', 'inlinecalculator',
63	'tasklist', 'tags', 'imagegenerators', 'tableofcontents',
64	'quicknote', 'attachmentbrowser', 'insertsymbol',
65	'sourceview', 'tableeditor', 'bookmarksbar', 'spell',
66	'arithmetic', 'linesorter', 'commandpalette'
67]
68
69
70_mydir = os.path.abspath(os.path.dirname(__file__))
71
72# when a test is missing from the list that should be detected
73for file in glob.glob(_mydir + '/*.py'):
74	name = os.path.basename(file)[:-3]
75	if name != '__init__' and not name in __all__:
76		raise AssertionError('Test missing in __all__: %s' % name)
77
78# get our own tmpdir
79TMPDIR = os.path.abspath(os.path.join(_mydir, 'tmp'))
80	# Wanted to use tempfile.get_tempdir here to put everything in
81	# e.g. /tmp/zim but since /tmp is often mounted as special file
82	# system this conflicts with thrash support. For writing in source
83	# dir we have conflict with bazaar controls, this is worked around
84	# by a config mode switch in the bazaar backend of the version
85	# control plugin
86
87# also get the default tmpdir and put a copy in the env
88REAL_TMPDIR = tempfile.gettempdir()
89
90
91def load_tests(loader, tests, pattern):
92	'''Load all test cases and return a unittest.TestSuite object.
93	The parameters 'tests' and 'pattern' are ignored.
94	'''
95	suite = unittest.TestSuite()
96	for name in ['tests.' + name for name in __all__]:
97		test = loader.loadTestsFromName(name)
98		suite.addTest(test)
99	return suite
100
101
102def _setUpEnvironment():
103	'''Method to be run once before test suite starts'''
104	# In fact to be loaded before loading some of the zim modules
105	# like zim.config and any that export constants from it
106
107	# NOTE: do *not* touch XDG_DATA_DIRS here because it is used by Gtk to
108	# find resources like pixbuf loaders etc. not finding these will lead to
109	# a crash. Especially under msys the defaults are not set but also not
110	# map to the default folders. So not touching it is the safest path.
111	# For zim internal usage this is overloaded in config.basedirs.set_basedirs()
112	os.environ.update({
113		'ZIM_TEST_RUNNING': 'True',
114		'ZIM_TEST_ROOT': os.getcwd(),
115		'TMP': TMPDIR,
116		'REAL_TMP': REAL_TMPDIR,
117		'XDG_DATA_HOME': os.path.join(TMPDIR, 'data_home'),
118		'TEST_XDG_DATA_DIRS': os.path.join(TMPDIR, 'data_dir'),
119		'XDG_CONFIG_HOME': os.path.join(TMPDIR, 'config_home'),
120		'XDG_CONFIG_DIRS': os.path.join(TMPDIR, 'config_dir'),
121		'XDG_CACHE_HOME': os.path.join(TMPDIR, 'cache_home'),
122		'XDG_RUNTIME_DIR': os.path.join(TMPDIR, 'runtime')
123	})
124
125	if os.path.isdir(TMPDIR):
126		shutil.rmtree(TMPDIR)
127	os.makedirs(TMPDIR)
128
129
130if os.environ.get('ZIM_TEST_RUNNING') != 'True':
131	# Do this when loaded, but not re-do in sub processes
132	# (doing so will kill e.g. the ipc test...)
133	_setUpEnvironment()
134
135
136## Setup special logging for tests
137
138class UncaughtWarningError(AssertionError):
139	pass
140
141
142class TestLoggingHandler(logging.Handler):
143	'''Handler class that raises uncaught errors to ensure test don't fail silently'''
144
145	def __init__(self, level=logging.WARNING):
146		logging.Handler.__init__(self, level)
147		fmt = logging.Formatter('%(levelname)s %(filename)s %(lineno)s: %(message)s')
148		self.setFormatter(fmt)
149
150	def emit(self, record):
151		if record.levelno >= logging.WARNING \
152		and not record.name.startswith('tests'):
153			raise UncaughtWarningError(self.format(record))
154		else:
155			pass
156
157logging.getLogger().addHandler(TestLoggingHandler())
158	# Handle all errors that make it up to the root level
159
160try:
161	logging.getLogger('zim.test').warning('foo')
162except UncaughtWarningError:
163	pass
164else:
165	raise AssertionError('Raising errors on warning fails')
166
167###
168
169
170from zim.newfs import LocalFolder
171
172import zim.config.manager
173import zim.plugins
174
175
176# Define runtime folder & data folders for use in test cases
177ZIM_SRC_FOLDER = LocalFolder(_mydir + '/..')
178ZIM_DATA_FOLDER = LocalFolder(_mydir + '/../data')
179TEST_SRC_FOLDER = LocalFolder(_mydir)
180TEST_DATA_FOLDER = LocalFolder(_mydir + '/data')
181
182
183_zim_pyfiles = []
184
185def zim_pyfiles():
186	'''Returns a list with file paths for all the zim python files'''
187	if not _zim_pyfiles:
188		for d, dirs, files in os.walk('zim'):
189			_zim_pyfiles.extend([d + '/' + f for f in files if f.endswith('.py')])
190		_zim_pyfiles.sort()
191	for file in _zim_pyfiles:
192		yield file # shallow copy
193
194
195def convert_path_sep(path):
196	if os.name == 'nt':
197		return path.replace('/', '\\')
198	else:
199		return path
200
201
202def slowTest(obj):
203	'''Decorator for slow tests
204
205	Tests wrapped with this decorator are ignored when you run
206	C{test.py --fast}. You can either wrap whole test classes::
207
208		@tests.slowTest
209		class MyTest(tests.TestCase):
210			...
211
212	or individual test functions::
213
214		class MyTest(tests.TestCase):
215
216			@tests.slowTest
217			def testFoo(self):
218				...
219
220			def testBar(self):
221				...
222	'''
223	if FAST_TEST:
224		wrapper = skip('Slow test')
225		return wrapper(obj)
226	else:
227		return obj
228
229
230MOCK_ALWAYS_MOCK = 'mock' #: Always choose mock folder, alwasy fast
231MOCK_DEFAULT_MOCK = 'default_mock' #: By default use mock, but sometimes at random use real fs or at --full
232MOCK_DEFAULT_REAL = 'default_real' #: By default use real fs, mock oly for --fast
233MOCK_ALWAYS_REAL = 'real' #: always use real fs -- not recommended unless test fails for mock
234
235import random
236import time
237
238TIMINGS = []
239
240class TestCase(unittest.TestCase):
241	'''Base class for test cases'''
242
243	maxDiff = None
244
245	mockConfigManager = True
246
247	def run(self, *a, **kwa):
248		start = time.time()
249		unittest.TestCase.run(self, *a, **kwa)
250		end = time.time()
251		TIMINGS.append((self.__class__.__name__ + '.' + self._testMethodName, end - start))
252
253	@classmethod
254	def setUpClass(cls):
255		if cls.mockConfigManager:
256			zim.config.manager.makeConfigManagerVirtual()
257			zim.plugins.resetPluginManager()
258
259	@classmethod
260	def tearDownClass(cls):
261		if Gtk is not None:
262			gtk_process_events() # flush any pending events / warnings
263
264		zim.config.manager.resetConfigManager()
265		zim.plugins.resetPluginManager()
266
267	def setUpFolder(self, name=None, mock=MOCK_DEFAULT_MOCK):
268		'''Convenience method to create a temporary folder for testing
269		@param name: name postfix for the folder
270		@param mock: mock level for this test, one of C{MOCK_ALWAYS_MOCK},
271		C{MOCK_DEFAULT_MOCK}, C{MOCK_DEFAULT_REAL} or C{MOCK_ALWAYS_REAL}.
272		The C{MOCK_ALWAYS_*} arguments force the use of a real folder or a
273		mock object. The C{MOCK_DEFAULT_*} arguments give a preference but
274		for these the behavior is overruled by "--fast" and "--full" in the
275		test script.
276		@returns: a L{Folder} object (either L{LocalFolder} or L{MockFolder})
277		that is guarenteed non-existing
278		'''
279		path = self._get_tmp_name(name)
280
281		if mock == MOCK_ALWAYS_MOCK:
282			use_mock = True
283		elif mock == MOCK_ALWAYS_REAL:
284			use_mock = False
285		else:
286			if FULL_TEST:
287				use_mock = False
288			elif FAST_TEST:
289				use_mock = True
290			else:
291				use_mock = (mock == MOCK_DEFAULT_MOCK)
292
293		if use_mock:
294			from zim.newfs.mock import MockFolder
295			folder = MockFolder(path)
296		else:
297			from zim.newfs import LocalFolder
298			if os.path.exists(path):
299				logger.debug('Clear tmp folder: %s', path)
300				shutil.rmtree(path)
301				assert not os.path.exists(path) # make real sure
302			folder = LocalFolder(path)
303
304		assert not folder.exists()
305		return folder
306
307	def setUpNotebook(self, name='testnotebook', mock=MOCK_ALWAYS_MOCK, content={}, folder=None):
308		'''
309		@param name: name postfix for the folder, see L{setUpFolder}, and name for the notebook
310		@param mock: see L{setUpFolder}, default is C{MOCK_ALWAYS_MOCK}
311		@param content: dictionary where the keys are page names and the
312		values the page content. If a tuple or list is given, pages are created
313		with default text. L{Path} objects are allowed instead of page names
314		@param folder: determine the folder to be used, only needed in special
315		cases where the folder must be outside of the project folder, like
316		when testing version control logic
317		'''
318		import datetime
319		from zim.newfs.mock import MockFolder
320		from zim.notebook.notebook import NotebookConfig, Notebook
321		from zim.notebook.page import Path
322		from zim.notebook.layout import FilesLayout
323		from zim.notebook.index import Index
324		from zim.formats.wiki import WIKI_FORMAT_VERSION
325
326		if folder is None:
327			folder = self.setUpFolder(name, mock)
328		folder.touch() # Must exist for sane notebook
329		cache_dir = folder.folder('.zim')
330		layout = FilesLayout(folder, endofline='unix')
331
332		conffile = folder.file('notebook.zim')
333		config = NotebookConfig(conffile)
334		config.write()
335		if isinstance(folder, MockFolder):
336			index = Index(':memory:', layout)
337		else:
338			f = cache_dir.file('index.db')
339			index = Index(cache_dir.file('index.db').path, layout)
340
341		if isinstance(content, (list, tuple)):
342			content = dict((p, 'test 123') for p in content)
343
344		notebook = Notebook(cache_dir, config, folder, layout, index)
345		notebook.properties['name'] = name
346		for name, text in list(content.items()):
347			path = Path(name) if isinstance(name, str) else name
348			file, folder = layout.map_page(path)
349			file.write(
350				(
351					'Content-Type: text/x-zim-wiki\n'
352					'Wiki-Format: %s\n'
353					'Creation-Date: %s\n\n'
354				) % (WIKI_FORMAT_VERSION, datetime.datetime.now().isoformat())
355				+ text
356			)
357
358		notebook.index.check_and_update()
359		assert notebook.index.is_uptodate
360		return notebook
361
362	def _get_tmp_name(self, postfix):
363		name = self.__class__.__name__
364		if self._testMethodName != 'runTest':
365			name += '_' + self._testMethodName
366
367		if postfix:
368			assert '/' not in postfix and '\\' not in postfix, 'Don\'t use this method to get sub folders or files'
369			name += '_' + postfix
370
371		return os.path.join(TMPDIR, name)
372
373
374class LoggingFilter(logging.Filter):
375	'''Convenience class to surpress zim errors and warnings in the
376	test suite. Acts as a context manager and can be used with the
377	'with' keyword.
378
379	Alternatively you can call L{wrap_test()} from test C{setUp}.
380	This will start the filter and make sure it is cleaned up again.
381	'''
382
383	# Due to how the "logging" module works, logging channels do inherit
384	# handlers of parents but not filters. Therefore setting a filter
385	# on the "zim" channel will not surpress messages from sub-channels.
386	# Instead we need to set the filter both on the channel and on
387	# top level handlers to get the desired effect.
388
389	def __init__(self, logger, message=None):
390		'''Constructor
391		@param logger: the logging channel name
392		@param message: can be a string, or a sequence of strings.
393		Any messages that start with this string or any of these
394		strings are surpressed.
395		'''
396		self.logger = logger
397		self.message = message
398
399	def __enter__(self):
400		logging.getLogger(self.logger).addFilter(self)
401		for handler in logging.getLogger().handlers:
402			handler.addFilter(self)
403
404	def __exit__(self, *a):
405		logging.getLogger(self.logger).removeFilter(self)
406		for handler in logging.getLogger().handlers:
407			handler.removeFilter(self)
408
409	def filter(self, record):
410		if record.name.startswith(self.logger):
411			msg = record.getMessage()
412			if self.message is None:
413				return False
414			elif isinstance(self.message, tuple):
415				return not any(msg.startswith(m) for m in self.message)
416			else:
417				return not msg.startswith(self.message)
418		else:
419			return True
420
421
422	def wrap_test(self, test):
423		self.__enter__()
424		test.addCleanup(self.__exit__)
425
426
427class DialogContext(object):
428	'''Context manager to catch dialogs being opened
429
430	Inteded to be used like this::
431
432		def myCustomTest(dialog):
433			self.assertTrue(isinstance(dialog, CustomDialogClass))
434			# ...
435			dialog.assert_response_ok()
436
437		with DialogContext(
438			myCustomTest,
439			SomeOtherDialogClass
440		):
441			gui.show_dialogs()
442
443	In this example the first dialog that is run by C{gui.show_dialogs()}
444	is checked by the function C{myCustomTest()} while the second dialog
445	just needs to be of class C{SomeOtherDialogClass} and will then
446	be closed with C{assert_response_ok()} by the context manager.
447
448	This context only works for dialogs derived from zim's Dialog class
449	as it uses a special hook in L{zim.gui.widgets}.
450	'''
451
452	def __init__(self, *definitions):
453		'''Constructor
454		@param definitions: list of either classes or methods
455		'''
456		self.stack = list(definitions)
457		self.old_test_mode = None
458
459	def __enter__(self):
460		import zim.gui.widgets
461		self.old_test_mode = zim.gui.widgets.TEST_MODE
462		self.old_callback = zim.gui.widgets.TEST_MODE_RUN_CB
463		zim.gui.widgets.TEST_MODE = True
464		zim.gui.widgets.TEST_MODE_RUN_CB = self._callback
465
466	def _callback(self, dialog):
467		#~ print('>>>', dialog)
468		if not self.stack:
469			raise AssertionError('Unexpected dialog run: %s' % dialog)
470
471		handler = self.stack.pop(0)
472
473		if isinstance(handler, type): # is a class
474			self._default_handler(handler, dialog)
475		elif isinstance(handler, tuple):
476			klass, func = handler
477			self._default_handler(klass, dialog, func)
478		else: # assume a function
479			handler(dialog)
480
481	def _default_handler(self, cls, dialog, func=None):
482		if not isinstance(dialog, cls):
483			raise AssertionError('Expected dialog of class %s, but got %s instead' % (cls, dialog.__class__))
484		if func:
485			func(dialog)
486		dialog.assert_response_ok()
487
488	def __exit__(self, *error):
489		import zim.gui.widgets
490		zim.gui.widgets.TEST_MODE = self.old_test_mode
491		zim.gui.widgets.TEST_MODE_RUN_CB = self.old_callback
492
493		has_error = any(error)
494		if self.stack and not has_error:
495			raise AssertionError('%i expected dialog(s) not run' % len(self.stack))
496
497		return False # Raise any errors again outside context
498
499
500class WindowContext(DialogContext):
501
502	def _default_handler(self, cls, window):
503		if not isinstance(window, cls):
504			raise AssertionError('Expected window of class %s, but got %s instead' % (cls, dialog.__class__))
505
506
507class ApplicationContext(object):
508
509	def __init__(self, *callbacks):
510		self.stack = list(callbacks)
511
512	def __enter__(self):
513		import zim.applications
514		self.old_test_mode = zim.applications.TEST_MODE
515		self.old_callback = zim.applications.TEST_MODE_RUN_CB
516		zim.applications.TEST_MODE = True
517		zim.applications.TEST_MODE_RUN_CB = self._callback
518
519	def _callback(self, cmd):
520		if not self.stack:
521			raise AssertionError('Unexpected application run: %s' % cmd)
522
523		handler = self.stack.pop(0)
524		return handler(cmd) # need to return for pipe()
525
526	def __exit__(self, *error):
527		import zim.gui.widgets
528		zim.applications.TEST_MODE = self.old_test_mode
529		zim.applications.TEST_MODE_RUN_CB = self.old_callback
530
531		if self.stack and not any(error):
532			raise AssertionError('%i expected command(s) not run' % len(self.stack))
533
534		return False # Raise any errors again outside context
535
536
537class ZimApplicationContext(object):
538
539	def __init__(self, *callbacks):
540		self.stack = list(callbacks)
541
542	def __enter__(self):
543		from zim.main import ZIM_APPLICATION
544		self.apps_obj = ZIM_APPLICATION
545		self.old_run = ZIM_APPLICATION._run_cmd
546		ZIM_APPLICATION._run_cmd = self._callback
547
548	def _callback(self, cmd, args):
549		if not self.stack:
550			raise AssertionError('Unexpected command run: %s %r' % (cmd, args))
551
552		handler = self.stack.pop(0)
553		handler(cmd, args)
554
555	def __exit__(self, *error):
556		self.apps_obj._run_cmd = self.old_run
557
558		if self.stack and not any(error):
559			raise AssertionError('%i expected command(s) not run' % len(self.stack))
560
561		return False # Raise any errors again outside context
562
563
564from zim.newfs.mock import os_native_path as _os_native_path
565
566def os_native_path(path, drive='C:'):
567	return _os_native_path(path, drive)
568
569
570class _TestData(object):
571	'''Wrapper for a set of test data in tests/data'''
572
573	def __init__(self):
574		root = os.environ['ZIM_TEST_ROOT']
575		tree = etree.ElementTree(file=root + '/tests/data/notebook-wiki.xml')
576
577		test_data = []
578		for node in tree.iter(tag='page'):
579			name = node.attrib['name']
580			text = str(node.text.lstrip('\n'))
581			test_data.append((name, text))
582
583		self._test_data = tuple(test_data)
584
585	def __iter__(self):
586		'''Yield the test data as 2 tuple (pagename, text)'''
587		for name, text in self._test_data:
588			yield name, text # shallow copy
589
590	def items(self):
591		return list(self)
592
593	def __getitem__(self, key):
594		return self.get(key)
595
596	def get(self, pagename):
597		'''Return text for a specific pagename'''
598		for n, text in self._test_data:
599			if n == pagename:
600				return text
601		assert False, 'Could not find data for page: %s' % pagename
602
603FULL_NOTEBOOK = _TestData() #: singleton to be used by various tests
604
605
606def _expand_manifest(names):
607	'''Build a set of all pages names and all namespaces that need to
608	exist to host those page names.
609	'''
610	manifest = set()
611	for name in names:
612		manifest.add(name)
613		while name.rfind(':') > 0:
614			i = name.rfind(':')
615			name = name[:i]
616			manifest.add(name)
617	return manifest
618
619
620_cache = {}
621
622def new_parsetree():
623	'''Returns a new ParseTree object for testing
624	Uses data from C{tests/data/formats/wiki.txt}
625	'''
626	if 'new_parsetree' not in _cache:
627		root = os.environ['ZIM_TEST_ROOT']
628		with open(root + '/tests/data/formats/wiki.txt', encoding='UTF-8') as file:
629			text = file.read()
630
631		import zim.formats.wiki
632		parser = zim.formats.wiki.Parser()
633		tree = parser.parse(text)
634
635		_cache['new_parsetree'] = tree
636
637	return _cache['new_parsetree'].copy()
638
639
640def new_parsetree_from_text(text, format='wiki'):
641	import zim.formats
642	parser = zim.formats.get_format(format).Parser()
643	return parser.parse(text)
644
645
646def new_parsetree_from_xml(xml):
647	# For some reason this does not work with cElementTree.XMLBuilder ...
648	from xml.etree.ElementTree import XMLParser
649	from zim.formats import ParseTree
650	builder = XMLParser()
651	builder.feed(xml)
652	root = builder.close()
653	return ParseTree(root)
654
655
656def new_page():
657	from zim.notebook import Path, Page
658	from zim.newfs.mock import MockFile, MockFolder
659	file = MockFile('/mock/test/page.txt')
660	folder = MockFile('/mock/test/page/')
661	page = Page(Path('Test'), False, file, folder)
662	page.set_parsetree(new_parsetree())
663	return page
664
665
666def new_page_from_text(text, format='wiki'):
667	from zim.notebook import Path, Page
668	from zim.newfs.mock import MockFile, MockFolder
669	file = MockFile('/mock/test/page.txt')
670	folder = MockFile('/mock/test/page/')
671	page = Page(Path('Test'), False, file, folder)
672	page.set_parsetree(new_parsetree_from_text(text, format))
673	return page
674
675
676class MockObject(object):
677	'''Simple class to quickly create mock objects
678
679	It allows defining methods with a static return value and logs the calls
680	to the defined methods
681
682	Example usage:
683
684		myobject = MockObject(return_values={'foo': "test 123"})
685			# define an object with a single method "foo" which returns "test 123"
686		object_to_be_tested.some_method(myobject)
687		self.assertEqual(myobject.allMethodCalls, [('foo', 'abc')])
688			# assert method called with single argument
689
690	'''
691
692	def __init__(self, methods=(), return_values={}):
693		'''Constructor
694		@param methods: a list of method names that are to be supported; these
695		methods will do nothing and return a C{None} value
696		@param return_values: mapping of method names to return value for the
697		method. Names do not have to be defined in C{methods}.
698		'''
699		self.allMethodCalls = []
700		self.__return_values = {}
701
702		for name in methods:
703			self.__return_values[name] = None
704
705		self.__return_values.update(return_values)
706
707	@property
708	def lastMethodCall(self):
709		return self.allMethodCalls[-1]
710
711	def __getattr__(self, name):
712		'''Automatically mock methods'''
713		if not name in self.__return_values:
714			raise AttributeError('No such method defined for MockObject: %s' % name)
715
716		return_value = self.__return_values[name]
717		def my_mock_method(*arg, **kwarg):
718			call = [name] + list(arg)
719			if kwarg:
720				call.append(kwarg)
721			self.allMethodCalls.append(tuple(call))
722			return return_value
723
724		setattr(self, name, my_mock_method)
725		return my_mock_method
726
727	def addMockMethod(self, name, return_value=None):
728		'''Installs a mock method with a given name that returns
729		a given value.
730		'''
731		self.__return_values[name] = return_value
732
733
734class CallBackLogger(list):
735	'''Object that is callable as a function and keeps count how often
736	it was called and with what arguments
737	'''
738
739	def __init__(self, return_value=None):
740		'''Constructor
741		@param return_value: the value to return when called as a function
742		'''
743		self.return_value = return_value
744		self.count = 0
745
746	@property
747	def hasBeenCalled(self):
748		return bool(self)
749
750	@property
751	def numberOfCalls(self):
752		return len(self)
753
754	def __call__(self, *arg, **kwarg):
755		self.count += 1
756
757		call = list(arg)
758		if kwarg:
759			call.append(kwarg)
760		self.append(tuple(call))
761
762		return self.return_value
763
764
765def wrap_method_with_logger(object, name):
766	orig_function = getattr(object, name)
767	wrapper = CallBackWrapper(orig_function)
768	setattr(object, name, wrapper)
769	return wrapper
770
771
772class CallBackWrapper(CallBackLogger):
773
774	def __init__(self, orig_function):
775		assert orig_function is not None
776		self.count = 0
777		self.orig_function = orig_function
778
779	def __call__(self, *arg, **kwarg):
780		self.count += 1
781
782		call = list(arg)
783		if kwarg:
784			call.append(kwarg)
785		self.append(tuple(call))
786
787		return self.orig_function(*arg, **kwarg)
788
789
790class SignalLogger(dict):
791	'''Listening object that attaches to all signals of the target and records
792	all signals calls in a dictionary of lists.
793
794	Example usage:
795
796		signals = SignalLogger(myobject)
797		... # some code causing signals to be emitted
798		self.assertEqual(signals['mysignal'], [args])
799			# assert "mysignal" is called once with "*args"
800
801	If you don't want to match all arguments, the "filter_func" can be used to
802	transform the arguments before logging.
803
804		filter_func(signal_name, object, args) --> args
805
806	'''
807
808	def __init__(self, obj, filter_func=None):
809		self._obj = obj
810		self._ids = []
811
812		if filter_func is None:
813			filter_func = lambda s, o, a: a
814
815		for signal in self._obj.__signals__:
816			seen = []
817			self[signal] = seen
818
819			def handler(seen, signal, obj, *a):
820				seen.append(filter_func(signal, obj, a))
821				logger.debug('Signal: %s %r', signal, a)
822
823			id = obj.connect(signal, partial(handler, seen, signal))
824			self._ids.append(id)
825
826	def __enter__(self):
827		pass
828
829	def __exit__(self, *e):
830		self.disconnect()
831
832	def clear(self):
833		for signal, seen in list(self.items()):
834			seen[:] = []
835
836	def disconnect(self):
837		for id in self._ids:
838			self._obj.disconnect(id)
839		self._ids = []
840
841
842class MaskedObject(object):
843	'''Proxy object that allows filtering what methods of an interface are
844	accesible.
845
846	Example Usage:
847
848		myproxy = MaskedObject(realobject, ('connect', 'disconnect'))
849			# proxy only allows calling "connect()" and "disconnect()"
850			# other calls lead to exception
851
852	'''
853
854	def __init__(self, obj, names):
855		self.__obj = obj
856		self.__names = names
857
858	def setObjectAccess(self, *names):
859		self.__names = names
860
861	def __getattr__(self, name):
862		if name in self.__names:
863			return getattr(self.__obj, name)
864		else:
865			raise AttributeError('Acces to \'%s\' not allowed' % name)
866
867
868def gtk_process_events(*a):
869	'''Method to simulate a few iterations of the gtk main loop'''
870	assert Gtk is not None
871	while Gtk.events_pending():
872		Gtk.main_iteration()
873	return True # continue
874
875
876def gtk_get_menu_item(menu, id):
877	'''Get a menu item from a C{Gtk.Menu}
878	@param menu: a C{Gtk.Menu}
879	@param id: either the menu item label or the stock id
880	@returns: a C{Gtk.MenuItem} or C{None}
881	'''
882	items = menu.get_children()
883	ids = [i.get_property('label') for i in items]
884		# Gtk.ImageMenuItems that have a stock id happen to use the
885		# 'label' property to store it...
886
887	assert id in ids, \
888		'Menu item "%s" not found, we got:\n' % id \
889		+ ''.join('- %s \n' % i for i in ids)
890
891	i = ids.index(id)
892	return items[i]
893
894
895def gtk_activate_menu_item(menu, id):
896	'''Trigger the 'click' action an a menu item
897	@param menu: a C{Gtk.Menu}
898	@param id: either the menu item label or the stock id
899	'''
900	item = gtk_get_menu_item(menu, id)
901	item.activate()
902
903
904def find_widgets(type):
905	'''Iterate through all top level windows and recursively walk through their
906	children, returning all childs which are of given type.
907	@param type: any class inherited from C{Gtk.Widget}
908	@returns: list of widgets of given type
909	'''
910	widgets = []
911	def walk_containers(root_container):
912		if not hasattr(root_container, 'get_children'):
913			return
914		for child in root_container.get_children():
915			if isinstance(child, type):
916				widgets.append(child)
917			walk_containers(child)
918	for window in Gtk.Window.list_toplevels():
919		walk_containers(window)
920	return widgets
921