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