Skip to content

Commit 1c000cc

Browse files
authored
Merge a8a3238 into ef31b1d
2 parents ef31b1d + a8a3238 commit 1c000cc

28 files changed

Lines changed: 300 additions & 35 deletions

devDocs/developerGuide.t2t

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,9 @@ The following few sections will talk separately about App Modules and Global Plu
195195
After this point, discussion is again more general.
196196

197197
++ Basics of an App Module ++
198-
App Module files have a .py extension, and are named the same as either the main executable of the application for which you wish them to be used or the package inside a host executable.
198+
App Module files have a .py extension, and in most cases should be named the same as either the main executable of the application for which you wish them to be used or the package inside a host executable.
199199
For example, an App Module for notepad would be called notepad.py, as notepad's main executable is called notepad.exe.
200+
If you want to use a single App Module for multiple executables, or the name of the executable conflicts with the standard Python import rules read [Associating App Modules with an executable #AssociatingAppModule].
200201
For apps hosted inside host executables, see the section on app modules for hosted apps.
201202

202203
App Module files must be placed in the appModules subdirectory of an add-on, or of the scratchpad directory of the NVDA user configuration directory.
@@ -208,6 +209,30 @@ This will all be covered in depth later.
208209
NVDA loads an App Module for an application as soon as it notices the application is running.
209210
The App Module is unloaded once the application is closed or when NVDA is exiting.
210211

212+
++ Associating App Modules with an executable ++[AssociatingAppModule]
213+
As explained above, sometimes the default way of associating an App Module with an application is not flexible enough. Examples include:
214+
- You want to use a single App Module for various binaries (perhaps both stable and preview versions of the application should have the same accessibility enhancements)
215+
- The executable file is named in a way which conflicts with the Python naming rules. I.e. for an application named "time", naming the App Module "time.py" would conflict with the built-in module from the standard library
216+
-
217+
218+
In such cases you can distribute a small global plugin along with your App Module which maps it to the executable.
219+
For example to map the App Module named "time_app_mod" to the "time" executable the plugin may be written as follows:
220+
```
221+
import appModuleHandler
222+
import globalPluginHandler
223+
224+
225+
class GlobalPlugin(globalPluginHandler.GlobalPlugin):
226+
227+
def __init__(self, *args, **kwargs):
228+
super().__init__(*args, **kwargs)
229+
appModuleHandler.registerExecutableWithAppModule("time", "time_app_mod")
230+
231+
def terminate(self, *args, **kwargs):
232+
super().terminate(*args, **kwargs)
233+
appModuleHandler.unregisterExecutable("time")
234+
```
235+
211236
++ Example 1: An App Module that Beeps on Focus Change Events ++[Example1]
212237
The following example App Module makes NVDA beep each time the focus changes within the notepad application.
213238
This example shows you the basic layout of an App Module.

devDocs/technicalDesignOverview.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@ An app module provides support specific to an application for these cases.
181181
An app module is derived from the `appModuleHandler.AppModule` base class.
182182
App modules receive events for all [NVDA objects](#nvda-objects) in the application and can bind scripts which can be executed anywhere in that application.
183183
They can also implement their own NVDA objects for use within the application.
184+
Usually the App Module should be named the same as the executable for which it should be loaded.
185+
In cases where this is problematic (one App Module should support multiple applications, the binary is named in a way which conflicts with the Python import system) you can add an entry to the `appModules.EXECUTABLE_NAMES_TO_APP_MODS` where the binary name is the key and the name of the App Module is the value.
184186

185187
#### Global Plugins
186188
Aside from application specific customisation using [app modules](#app-modules), it is also possible to extend NVDA on a global level.

source/appModuleHandler.py

Lines changed: 156 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@
1515
import ctypes.wintypes
1616
import os
1717
import sys
18+
from types import ModuleType
1819
from typing import (
1920
Dict,
21+
List,
2022
Optional,
23+
Tuple,
24+
Union,
2125
)
26+
import zipimport
2227

2328
import winVersion
2429
import pkgutil
@@ -38,11 +43,13 @@
3843
import extensionPoints
3944
from fileUtils import getFileVersionInfo
4045

46+
_KNOWN_IMPORTERS_T = Union[importlib.machinery.FileFinder, zipimport.zipimporter]
4147
# Dictionary of processID:appModule pairs used to hold the currently running modules
4248
runningTable: Dict[int, AppModule] = {}
4349
#: The process ID of NVDA itself.
4450
NVDAProcessID=None
45-
_importers=None
51+
_CORE_APP_MODULES_PATH: os.PathLike = appModules.__path__[0]
52+
_importers: Optional[List[_KNOWN_IMPORTERS_T]] = None
4653
_getAppModuleLock=threading.RLock()
4754
#: Notifies when another application is taking foreground.
4855
#: This allows components to react upon application switches.
@@ -51,6 +58,13 @@
5158
post_appSwitch = extensionPoints.Action()
5259

5360

61+
_executableNamesToAppModsAddons: Dict[str, str] = dict()
62+
"""AppModules registered with a given binary by add-ons are placed here.
63+
We cannot use l{appModules.EXECUTABLE_NAMES_TO_APP_MODS} for modules included in add-ons,
64+
since appModules in add-ons should take precedence over the one bundled in NVDA.
65+
"""
66+
67+
5468
class processEntry32W(ctypes.Structure):
5569
_fields_ = [
5670
("dwSize",ctypes.wintypes.DWORD),
@@ -65,14 +79,121 @@ class processEntry32W(ctypes.Structure):
6579
("szExeFile", ctypes.c_wchar * 260)
6680
]
6781

68-
def getAppNameFromProcessID(processID,includeExt=False):
82+
83+
def _warnDeprecatedAliasAppModule() -> None:
84+
"""This function should be executed at the top level of an alias App Module,
85+
to log a deprecation warning when the module is imported.
86+
"""
87+
import inspect
88+
# Determine the name of the module inside which this function is executed by using introspection.
89+
# Since the current frame belongs to the calling function inside `appModuleHandler`
90+
# we need to retrieve the file name from the preceding frame which belongs to the module in which this
91+
# function is executed.
92+
currModName = os.path.splitext(os.path.basename(inspect.stack()[1].filename))[0]
93+
try:
94+
replacementModName = appModules.EXECUTABLE_NAMES_TO_APP_MODS[currModName]
95+
except KeyError:
96+
raise RuntimeError("This function can be executed only inside an alias App Module.") from None
97+
else:
98+
log.warning(
99+
(
100+
f"Importing from appModules.{currModName} is deprecated,"
101+
f" you should import from appModules.{replacementModName}."
102+
)
103+
)
104+
105+
106+
def registerExecutableWithAppModule(executableName: str, appModName: str) -> None:
107+
"""Registers appModule to be used for a given executable.
108+
"""
109+
_executableNamesToAppModsAddons[executableName] = appModName
110+
111+
112+
def unregisterExecutable(executableName: str) -> None:
113+
"""Removes the executable of a given name from the mapping of applications to appModules.
114+
"""
115+
try:
116+
del _executableNamesToAppModsAddons[executableName]
117+
except KeyError:
118+
log.error(f"Executable {executableName} was not previously registered.")
119+
120+
121+
def _getPathFromImporter(importer: _KNOWN_IMPORTERS_T) -> os.PathLike:
122+
try: # Standard `FileFinder` instance
123+
return importer.path
124+
except AttributeError:
125+
try: # Special case for `zipimporter`
126+
return os.path.normpath(os.path.join(importer.archive, importer.prefix))
127+
except AttributeError:
128+
raise TypeError(f"Cannot retrieve path from {repr(importer)}") from None
129+
130+
131+
def _getPossibleAppModuleNamesForExecutable(executableName: str) -> Tuple[str, ...]:
132+
"""Returns list of the appModule names for a given executable.
133+
The names in the tuple are placed in order in which import of these aliases should be attempted that is:
134+
- The alias registered by add-ons if any add-on registered an appModule for the executable
135+
- Just the name of the executable to cover a standard appModule named the same as the executable
136+
- The alias from `appModules.EXECUTABLE_NAMES_TO_APP_MODS` if it exists.
137+
"""
138+
return tuple(
139+
aliasName for aliasName in (
140+
_executableNamesToAppModsAddons.get(executableName),
141+
executableName,
142+
appModules.EXECUTABLE_NAMES_TO_APP_MODS.get(executableName)
143+
) if aliasName is not None
144+
)
145+
146+
147+
def doesAppModuleExist(name: str, ignoreDeprecatedAliases: bool = False) -> bool:
148+
"""Returns c{True} if App Module with a given name exists, c{False} otherwise.
149+
:param ignoreDeprecatedAliases: used for backward compatibility, so that by default alias modules
150+
are not excluded.
151+
"""
152+
for importer in _importers:
153+
modExists = importer.find_module(f"appModules.{name}")
154+
if modExists:
155+
# While the module has been found it is possible tis is just a deprecated alias.
156+
# Before PR #13366 the only possibility to map a single app module to multiple executables
157+
# was to create a alias app module and import everything from the main module into it.
158+
# Now the preferred solution is to add an entry into `appModules.EXECUTABLE_NAMES_TO_APP_MODS`,
159+
# but old alias modules have to stay to preserve backwards compatibility.
160+
# We cannot import the alias module since they show a deprecation warning on import.
161+
# To determine if the module should be imported or not we check if:
162+
# - it is placed in the core appModules package, and
163+
# - it has an alias defined in `appModules.EXECUTABLE_NAMES_TO_APP_MODS`.
164+
# If both of these are true the module should not be imported in core.
165+
if (
166+
ignoreDeprecatedAliases
167+
and name in appModules.EXECUTABLE_NAMES_TO_APP_MODS
168+
and _getPathFromImporter(importer) == _CORE_APP_MODULES_PATH
169+
):
170+
continue
171+
return True
172+
return False # None of the aliases exists
173+
174+
175+
def _importAppModuleForExecutable(executableName: str) -> Optional[ModuleType]:
176+
"""Import and return appModule for a given executable or `None` if there is no module.
177+
"""
178+
for possibleModName in _getPossibleAppModuleNamesForExecutable(executableName):
179+
# First, check whether the module exists.
180+
# We need to do this separately to exclude alias modules,
181+
# and because even though an ImportError is raised when a module can't be found,
182+
# it might also be raised for other reasons.
183+
if doesAppModuleExist(possibleModName, ignoreDeprecatedAliases=True):
184+
return importlib.import_module(
185+
f"appModules.{possibleModName}",
186+
package="appModules"
187+
)
188+
return None # Module not found
189+
190+
191+
def getAppNameFromProcessID(processID: int, includeExt: bool = False) -> str:
69192
"""Finds out the application name of the given process.
70193
@param processID: the ID of the process handle of the application you wish to get the name of.
71-
@type processID: int
72-
@param includeExt: C{True} to include the extension of the application's executable filename, C{False} to exclude it.
73-
@type window: bool
194+
@param includeExt: C{True} to include the extension of the application's executable filename,
195+
C{False} to exclude it.
74196
@returns: application name
75-
@rtype: str
76197
"""
77198
if processID==NVDAProcessID:
78199
return "nvda.exe" if includeExt else "nvda"
@@ -95,20 +216,20 @@ def getAppNameFromProcessID(processID,includeExt=False):
95216
# This might be an executable which hosts multiple apps.
96217
# Try querying the app module for the name of the app being hosted.
97218
try:
98-
mod = importlib.import_module("appModules.%s" % appName, package="appModules")
99-
return mod.getAppNameFromHost(processID)
100-
except (ImportError, AttributeError, LookupError):
219+
return _importAppModuleForExecutable(appName).getAppNameFromHost(processID)
220+
except (AttributeError, LookupError):
101221
pass
102222
return appName
103223

224+
104225
def getAppModuleForNVDAObject(obj):
105226
if not isinstance(obj,NVDAObjects.NVDAObject):
106227
return
107228
return getAppModuleFromProcessID(obj.processID)
108229

109230

110231
def getAppModuleFromProcessID(processID: int) -> AppModule:
111-
"""Finds the appModule that is for the given process ID. The module is also cached for later retreavals.
232+
"""Finds the appModule that is for the given process ID. The module is also cached for later retrievals.
112233
@param processID: The ID of the process for which you wish to find the appModule.
113234
@returns: the appModule
114235
"""
@@ -153,39 +274,36 @@ def cleanup():
153274
except:
154275
log.exception("Error terminating app module %r" % deadMod)
155276

156-
def doesAppModuleExist(name):
157-
return any(importer.find_module("appModules.%s" % name) for importer in _importers)
158277

159-
def fetchAppModule(processID,appName):
278+
def fetchAppModule(processID: int, appName: str) -> AppModule:
160279
"""Returns an appModule found in the appModules directory, for the given application name.
161280
@param processID: process ID for it to be associated with
162-
@type processID: integer
163281
@param appName: the application name for which an appModule should be found.
164-
@type appName: str
165-
@returns: the appModule, or None if not found
166-
@rtype: AppModule
167-
"""
168-
# First, check whether the module exists.
169-
# We need to do this separately because even though an ImportError is raised when a module can't be found, it might also be raised for other reasons.
282+
@returns: the appModule.
283+
"""
170284
modName = appName
171285

172-
if doesAppModuleExist(modName):
173-
try:
174-
return importlib.import_module("appModules.%s" % modName, package="appModules").AppModule(processID, appName)
175-
except:
176-
log.exception(f"error in appModule {modName!r}")
177-
import ui
178-
import speech.priorities
179-
ui.message(
180-
# Translators: This is presented when errors are found in an appModule
181-
# (example output: error in appModule explorer).
182-
_("Error in appModule %s") % modName,
183-
speechPriority=speech.priorities.Spri.NOW
184-
)
286+
try:
287+
importedMod = _importAppModuleForExecutable(modName)
288+
if importedMod is not None:
289+
return importedMod.AppModule(processID, appName)
290+
# Broad except since we do not know
291+
# what exceptions may be thrown during import / construction of the App Module.
292+
except Exception:
293+
log.exception(f"error in appModule {modName!r}")
294+
import ui
295+
import speech.priorities
296+
ui.message(
297+
# Translators: This is presented when errors are found in an appModule
298+
# (example output: error in appModule explorer).
299+
_("Error in appModule %s") % modName,
300+
speechPriority=speech.priorities.Spri.NOW
301+
)
185302

186303
# Use the base AppModule.
187304
return AppModule(processID, appName)
188305

306+
189307
def reloadAppModules():
190308
"""Reloads running appModules.
191309
especially, it clears the cache of running appModules and deletes them from sys.modules.
@@ -311,7 +429,11 @@ class AppModule(baseObject.ScriptableObject):
311429
Each app module should be a Python module or a package in the appModules package
312430
named according to the executable it supports;
313431
e.g. explorer.py for the explorer.exe application or firefox/__init__.py for firefox.exe.
314-
It should containa C{AppModule} class which inherits from this base class.
432+
If the name of the executable is not compatible with the Python's import system
433+
i.e. contains some special characters such as "." or "+" you can name the module however you like
434+
and then map the executable name to the module name
435+
by adding an entry to `appModules.EXECUTABLE_NAMES_TO_APP_MODS` dictionary.
436+
It should contain a C{AppModule} class which inherits from this base class.
315437
App modules can implement and bind gestures to scripts.
316438
These bindings will only take effect while an object in the associated application has focus.
317439
See L{ScriptableObject} for details.

source/appModules/__init__.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2009-2022 NV Access Limited, Łukasz Golonka
3+
# This file may be used under the terms of the GNU General Public License, version 2 or later.
4+
# For more details see: https://www.gnu.org/licenses/gpl-2.0.html
5+
6+
from typing import Dict
7+
8+
9+
EXECUTABLE_NAMES_TO_APP_MODS: Dict[str, str] = {
10+
# Azure Data Studio (both stable and Insiders versions) should use module for Visual Studio Code
11+
"azuredatastudio": "code",
12+
"azuredatastudio-insiders": "code",
13+
# Windows 11 calculator should use module for the Windows 10 one.
14+
"calculatorapp": "calculator",
15+
# The Insider version of Visual Studio Code should use the module for the stable version.
16+
"code - insiders": "code",
17+
# commsapps is an alias for the Windows 10 mail and calendar.
18+
"commsapps": "hxmail",
19+
# DBeaver is based on Eclipse and should use its appModule.
20+
"dbeaver": "eclipse",
21+
# Preview version of the Adobe Digital Editions should use the module for the stable version.
22+
"digitaleditionspreview": "digitaleditions",
23+
# Esybraille should use module for esysuite.
24+
"esybraille": "esysuite",
25+
# hxoutlook is an alias for Windows 10 mail in Creators update.
26+
"hxoutlook": "hxmail",
27+
# 64-bit versions of Miranda IM should use module for the 32-bit executable.
28+
"miranda64": "miranda32",
29+
# Various incarnations of Media Player Classic.
30+
"mpc-hc": "mplayerc",
31+
"mpc-hc64": "mplayerc",
32+
# The binary file for Notepad++ is named `notepad++` which makes its appModule not importable
33+
# (Python's import statement cannot deal with `+` in the file name).
34+
# Therefore the module is named `notepadPlusPlus` and mapped to the right binary below.
35+
"notepad++": "notepadPlusPlus",
36+
# searchapp is an alias for searchui in Windows 10 build 18965 and later.
37+
"searchapp": "searchui",
38+
# Windows search in Windows 11.
39+
"searchhost": "searchui",
40+
# Spring Tool Suite is based on Eclipse and should use its appModule.
41+
"springtoolsuite4": "eclipse",
42+
"sts": "eclipse",
43+
# Various versions of Teamtalk.
44+
"teamtalk3": "teamtalk4classic",
45+
# App module for Windows 10/11 Modern Keyboard aka new touch keyboard panel
46+
# should use Composable Shell modern keyboard app module
47+
"textinputhost": "windowsinternal_composableshell_experiences_textinput_inputapp",
48+
# Total Commander X64 should use the module for the 32-bit version.
49+
"totalcmd64": "totalcmd",
50+
# The calculator on Windows Server and LTSB versions of Windows 10
51+
# should use the module for the desktop calculator from the earlier Windows releases.
52+
"win32calc": "calc",
53+
# Windows Mail should use module for Outlook Express.
54+
"winmail": "msimn",
55+
# Zend Eclipse PHP Developer Tools is based on Eclipse and should use its appModule.
56+
"zend-eclipse-php": "eclipse",
57+
# Zend Studio is based on Eclipse and should use its appModule.
58+
"zendstudio": "eclipse",
59+
}
60+
61+
"""Maps names of the executables to the names of the appModule which should be loaded for the given program.
62+
Note that this map is used only for appModules included in NVDA
63+
and appModules registered by add-ons are placed in a different one.
64+
This mapping is needed since:
65+
- Names of some programs are incompatible with the Python's import system (they contain a dot or a plus)
66+
- Sometimes it is necessary to map one module to multiple executables - this map saves us from adding multiple
67+
appModules in such cases.
68+
"""

source/appModules/azuredatastudio-insiders.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@
99

1010
# Ignoring Flake8 imported but unused error since appModuleHandler yet uses the import.
1111
from .azuredatastudio import AppModule # noqa: F401
12+
from appModuleHandler import _warnDeprecatedAliasAppModule
13+
_warnDeprecatedAliasAppModule()

0 commit comments

Comments
 (0)