"""Utilities for the pywin32 tests""" from __future__ import annotations import gc import os import site import sys import unittest from collections import Counter from typing import TYPE_CHECKING import pywintypes import winerror from pythoncom import _GetGatewayCount, _GetInterfaceCount from win32com.shell.shell import IsUserAnAdmin if TYPE_CHECKING: from _typeshed import OptExcInfo ## ## unittest related stuff ## # This is a specialized TestCase adaptor which wraps a real test. class LeakTestCase(unittest.TestCase): """An 'adaptor' which takes another test. In debug builds we execute the test once to remove one-off side-effects, then capture the total reference count, then execute the test a few times. If the total refcount at the end is greater than we first captured, we have a leak! In release builds the test is executed just once, as normal. Generally used automatically by the test runner - you can safely ignore this. """ def __init__(self, real_test): unittest.TestCase.__init__(self) self.real_test = real_test self.num_test_cases = 1 self.num_leak_iters = 2 # seems to be enough! if hasattr(sys, "gettotalrefcount"): self.num_test_cases += self.num_leak_iters def countTestCases(self): return self.num_test_cases def __call__(self, result=None): # For the COM suite's sake, always ensure we don't leak gateways/interfaces gc.collect() ni = _GetInterfaceCount() ng = _GetGatewayCount() self.real_test(result) # Failed - no point checking anything else if result.shouldStop or not result.wasSuccessful(): return self._do_leak_tests(result) gc.collect() lost_i = _GetInterfaceCount() - ni lost_g = _GetGatewayCount() - ng if lost_i or lost_g: msg = "%d interface objects and %d gateway objects leaked" % ( lost_i, lost_g, ) exc = AssertionError(msg) result.addFailure(self.real_test, (exc.__class__, exc, None)) def runTest(self): raise NotImplementedError("not used") def _do_leak_tests(self, result=None): try: gtrc = sys.gettotalrefcount except AttributeError: return # can't do leak tests in this build # Assume already called once, to prime any caches etc gc.collect() trc = gtrc() for i in range(self.num_leak_iters): self.real_test(result) if result.shouldStop: break del i # created after we remembered the refcount! # int division here means one or 2 stray references won't force # failure, but one per loop gc.collect() lost = (gtrc() - trc) // self.num_leak_iters if lost < 0: msg = "LeakTest: %s appeared to gain %d references!!" % ( self.real_test, -lost, ) result.addFailure(self.real_test, (AssertionError, msg, None)) if lost > 0: msg = "LeakTest: %s lost %d references" % (self.real_test, lost) exc = AssertionError(msg) result.addFailure(self.real_test, (exc.__class__, exc, None)) class TestLoader(unittest.TestLoader): def loadTestsFromTestCase(self, testCaseClass): """Return a suite of all tests cases contained in testCaseClass""" leak_tests = [] for name in self.getTestCaseNames(testCaseClass): real_test = testCaseClass(name) leak_test = self._getTestWrapper(real_test) leak_tests.append(leak_test) return self.suiteClass(leak_tests) def fixupTestsForLeakTests(self, test): if isinstance(test, unittest.TestSuite): test._tests = [self.fixupTestsForLeakTests(t) for t in test._tests] return test else: # just a normal test case. return self._getTestWrapper(test) def _getTestWrapper(self, test): # one or 2 tests in the COM test suite set this... no_leak_tests = getattr(test, "no_leak_tests", False) if no_leak_tests: print("Test says it doesn't want leak tests!") return test return LeakTestCase(test) def loadTestsFromModule(self, mod): if hasattr(mod, "suite"): tests = mod.suite() else: tests = unittest.TestLoader.loadTestsFromModule(self, mod) return self.fixupTestsForLeakTests(tests) def loadTestsFromName(self, name, module=None): test = unittest.TestLoader.loadTestsFromName(self, name, module) if isinstance(test, unittest.TestSuite): # print("Don't wrap suites yet!", test._tests) pass # hmmm? elif isinstance(test, unittest.TestCase): test = self._getTestWrapper(test) else: print("XXX - what is", test) return test # Lots of classes necessary to support one simple feature: we want a 3rd # test result state - "SKIPPED" - to indicate that the test wasn't able # to be executed for various reasons. Inspired by bzr's tests, but it # has other concepts, such as "Expected Failure", which we don't bother # with. # win32 error codes that probably mean we need to be elevated (ie, if we # aren't elevated, we treat these error codes as 'skipped') non_admin_error_codes = { winerror.ERROR_ACCESS_DENIED, winerror.ERROR_PRIVILEGE_NOT_HELD, } # Find a test "fixture" (eg, binary test file) expected to be very close to # the test being run. # If the tests are being run from the "installed" version, then these fixtures # probably don't exist - the test is "skipped". # But it's fatal if we think we might be running from a pywin32 source tree. def find_test_fixture(basename, extra_dir="."): # look for the test file in various places candidates = [ os.path.dirname(sys.argv[0]), extra_dir, ".", ] for candidate in candidates: fname = os.path.join(candidate, basename) if os.path.isfile(fname): return fname else: # Can't find it - see if this is expected or not. # This module is typically always in the installed dir, so use argv[0] this_file = os.path.normcase(os.path.abspath(sys.argv[0])) dirs_to_check = site.getsitepackages()[:] if site.USER_SITE: dirs_to_check.append(site.USER_SITE) for d in dirs_to_check: d = os.path.normcase(d) if os.path.commonprefix([this_file, d]) == d: # looks like we are in an installed Python, so skip the text. raise unittest.SkipTest(f"Can't find test fixture '{fname}'") # Looks like we are running from source, so this is fatal. raise RuntimeError(f"Can't find test fixture '{fname}'") # The 'TestResult' subclass that records the failures and has the special # handling for the unittest.SkipTest exception. class TestResult(unittest.TextTestResult): def addError(self, test: unittest.TestCase, err: OptExcInfo) -> None: """Called when an error has occurred. Translate a couple of 'well-known' exceptions into 'skipped' """ exc_val = err[1] # translate ERROR_ACCESS_DENIED for non-admin users to be skipped. # (access denied errors for an admin user aren't expected.) if ( isinstance(exc_val, pywintypes.error) and exc_val.winerror in non_admin_error_codes and not IsUserAnAdmin() ): return self.addSkip(test, str(exc_val)) # and COM errors due to objects not being registered (the com test # suite will attempt to catch this and handle it itself if the user # is admin) elif isinstance(exc_val, pywintypes.com_error) and exc_val.hresult in { winerror.CO_E_CLASSSTRING, winerror.REGDB_E_CLASSNOTREG, winerror.TYPE_E_LIBNOTREGISTERED, }: return self.addSkip(test, str(exc_val)) # NotImplemented generally means the platform doesn't support the functionality. elif isinstance(exc_val, NotImplementedError): return self.addSkip(test, str(exc_val)) super().addError(test, err) def printErrors(self) -> None: super().printErrors() reasons = [reason for (_, reason) in self.skipped] for reason, num_skipped in Counter(reasons).items(): self.stream.writeln(f"SKIPPED: {num_skipped} tests - {reason}") # TestRunner subclass necessary just to get our TestResult hooked up. class TestRunner(unittest.TextTestRunner): def _makeResult(self): return TestResult(self.stream, self.descriptions, self.verbosity) # TestProgram subclass necessary just to get our TestRunner hooked up, # which is necessary to get our TestResult hooked up *sob* class TestProgram(unittest.TestProgram): def runTests(self): # clobber existing runner - *sob* - it shouldn't be this hard self.testRunner = TestRunner(verbosity=self.verbosity) unittest.TestProgram.runTests(self) # A convenient entry-point - if used, 'SKIPPED' exceptions will be suppressed. def testmain(*args, **kw): new_kw = kw.copy() if "testLoader" not in new_kw: new_kw["testLoader"] = TestLoader() program_class = new_kw.get("testProgram", TestProgram) program_class(*args, **new_kw)