Skip to content

Commit e487581

Browse files
LeonarddeRclaude
andcommitted
Fixup for nvaccess#18971: fix module cache cleanup in Addon._cleanupAddonImports
_cleanupAddonImports now matches modules on their file path against the add-on directory, case insensitively and anchored at a path separator. Modules without a file attribute are skipped. Add unit tests covering the cleanup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b0ab7a1 commit e487581

3 files changed

Lines changed: 111 additions & 3 deletions

File tree

‎source/addonHandler/__init__.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# A part of NonVisual Desktop Access (NVDA)
22
# Copyright (C) 2012-2026 NV Access Limited, Rui Batista, Noelia Ruiz Martínez, Joseph Lee, Babbage B.V.,
33
# Arnold Loubriat, Łukasz Golonka, Leonard de Ruijter, Julien Cochuyt, Cyrille Bougot
4-
# This file is covered by the GNU General Public License.
5-
# See the file COPYING for more details.
4+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
5+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
66

77
from __future__ import annotations # Avoids quoting of forward references
88

@@ -888,9 +888,11 @@ def _cleanupAddonImports(self) -> None:
888888
log.debug(f"removing imported add-on module {modName}")
889889
del sys.modules[modName]
890890
self._importedAddonModules.clear()
891+
addonPathPrefix = os.path.join(os.path.normcase(self.path), "")
891892
for modName in set(sys.modules.keys()) - self._modulesBeforeInstall:
892893
module = sys.modules[modName]
893-
if module.__name__ and module.__name__.startswith(self.path):
894+
moduleFile = getattr(module, "__file__", None)
895+
if moduleFile and os.path.normcase(moduleFile).startswith(addonPathPrefix):
894896
log.debug(f"Removing module {module} from cache of imported modules")
895897
del sys.modules[modName]
896898

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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, [])

‎user_docs/en/changes.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ Previously these keys had no function when pressed on their own. (#20366, @fla-r
9393
* In Mozilla Firefox and Chromium based browsers with native selection mode enabled, the caret no longer gets stuck when switching to focus mode, and typing in edit fields works again. (#19075, #18028, @LeonarddeR)
9494
* In Windows Terminal, mouse tracking now reports the line of text under the mouse pointer. (#20448, @DataTriny)
9595
* NVDA no longer floods its log with errors while Windows is locked and a browse mode document keeps updating in the background, such as a playing video in Mozilla Firefox. (#18861, @bramd)
96+
* Updating an add-on no longer leaves modules of the old version loaded. This could cause errors in the updated add-on on the first start of NVDA after the update. (#18971, @LeonarddeR)
9697

9798
### Changes for Developers
9899

0 commit comments

Comments
 (0)