|
| 1 | +# A part of NonVisual Desktop Access (NVDA) |
| 2 | +# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter |
| 3 | +# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license. |
| 4 | +# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt |
| 5 | + |
| 6 | +"""Unit tests for the cleanup of add-on modules imported into sys.modules.""" |
| 7 | + |
| 8 | +import os |
| 9 | +import sys |
| 10 | +import tempfile |
| 11 | +import types |
| 12 | +import unittest |
| 13 | + |
| 14 | +import addonHandler |
| 15 | + |
| 16 | +MANIFEST_CONTENTS = """name = testAddon |
| 17 | +summary = Test add-on |
| 18 | +author = Test author |
| 19 | +version = 0.1 |
| 20 | +""" |
| 21 | + |
| 22 | +ADDON_MODULE_CONTENTS: dict[str, str] = { |
| 23 | + "__init__.py": "", |
| 24 | + "sibling.py": "", |
| 25 | + "mainMod.py": "from . import sibling\n", |
| 26 | +} |
| 27 | + |
| 28 | + |
| 29 | +class TestCleanupAddonImports(unittest.TestCase): |
| 30 | + """Tests for the removal of an add-on's modules from sys.modules by Addon._cleanupAddonImports.""" |
| 31 | + |
| 32 | + def setUp(self) -> None: |
| 33 | + self._modulesBefore = set(sys.modules) |
| 34 | + self._tempDir = tempfile.TemporaryDirectory() |
| 35 | + addonPath = os.path.join(self._tempDir.name, "testAddon") |
| 36 | + libPath = os.path.join(addonPath, "lib") |
| 37 | + os.makedirs(libPath) |
| 38 | + with open(os.path.join(addonPath, addonHandler.MANIFEST_FILENAME), "w", encoding="utf-8") as f: |
| 39 | + f.write(MANIFEST_CONTENTS) |
| 40 | + for fileName, contents in ADDON_MODULE_CONTENTS.items(): |
| 41 | + with open(os.path.join(libPath, fileName), "w", encoding="utf-8") as f: |
| 42 | + f.write(contents) |
| 43 | + self.addon = addonHandler.Addon(addonPath) |
| 44 | + self.addon._modulesBeforeInstall = set(sys.modules) |
| 45 | + |
| 46 | + def tearDown(self) -> None: |
| 47 | + for modName in set(sys.modules) - self._modulesBefore: |
| 48 | + del sys.modules[modName] |
| 49 | + self._tempDir.cleanup() |
| 50 | + |
| 51 | + def _injectModule(self, name: str) -> types.ModuleType: |
| 52 | + """Adds a synthetic module to sys.modules and returns it. |
| 53 | +
|
| 54 | + :param name: The name to register the module under. |
| 55 | + :return: The registered module. |
| 56 | + """ |
| 57 | + module = types.ModuleType(name) |
| 58 | + sys.modules[name] = module |
| 59 | + return module |
| 60 | + |
| 61 | + def test_transitivelyImportedAddonModuleRemoved(self): |
| 62 | + """A module imported by another add-on module rather than by loadModule is removed.""" |
| 63 | + self.addon.loadModule("lib.mainMod") |
| 64 | + siblingName = "addons.testAddon.lib.sibling" |
| 65 | + self.assertIn(siblingName, sys.modules) |
| 66 | + self.assertNotIn(siblingName, self.addon._importedAddonModules) |
| 67 | + self.addon._cleanupAddonImports() |
| 68 | + self.assertNotIn(siblingName, sys.modules) |
| 69 | + |
| 70 | + def test_addonModuleRemovedRegardlessOfPathCase(self): |
| 71 | + """A module whose file path differs from the add-on path only in case is removed.""" |
| 72 | + module = self._injectModule("fakeCasedAddonModule") |
| 73 | + module.__file__ = os.path.join(self.addon.path.upper(), "lib", "mod.py") |
| 74 | + self.addon._cleanupAddonImports() |
| 75 | + self.assertNotIn("fakeCasedAddonModule", sys.modules) |
| 76 | + |
| 77 | + def test_moduleWithoutFileAttributeKept(self): |
| 78 | + """A module without a __file__ attribute survives the cleanup.""" |
| 79 | + self._injectModule("fakeBuiltinModule") |
| 80 | + self.addon._cleanupAddonImports() |
| 81 | + self.assertIn("fakeBuiltinModule", sys.modules) |
| 82 | + |
| 83 | + def test_moduleFromSiblingPathPrefixKept(self): |
| 84 | + """A module from a directory whose path starts with the add-on path survives the cleanup.""" |
| 85 | + module = self._injectModule("fakeSiblingPathModule") |
| 86 | + module.__file__ = os.path.join(self.addon.path + "Extra", "mod.py") |
| 87 | + self.addon._cleanupAddonImports() |
| 88 | + self.assertIn("fakeSiblingPathModule", sys.modules) |
| 89 | + |
| 90 | + def test_moduleWithNoneFileKept(self): |
| 91 | + """A module whose __file__ attribute is None survives the cleanup.""" |
| 92 | + module = self._injectModule("fakeNamespaceModule") |
| 93 | + module.__file__ = None |
| 94 | + self.addon._cleanupAddonImports() |
| 95 | + self.assertIn("fakeNamespaceModule", sys.modules) |
| 96 | + |
| 97 | + def test_recordedModulesRemovedAndListCleared(self): |
| 98 | + """Modules recorded by loadModule are removed and the record is emptied.""" |
| 99 | + self.addon.loadModule("lib.mainMod") |
| 100 | + recordedNames = list(self.addon._importedAddonModules) |
| 101 | + self.assertIn("addons.testAddon.lib.mainMod", recordedNames) |
| 102 | + self.addon._cleanupAddonImports() |
| 103 | + for modName in recordedNames: |
| 104 | + self.assertNotIn(modName, sys.modules) |
| 105 | + self.assertEqual(self.addon._importedAddonModules, []) |
0 commit comments