-
-
Notifications
You must be signed in to change notification settings - Fork 711
Split addonHandler module #17797
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Split addonHandler module #17797
Changes from 19 commits
79aefb4
6d017cd
1dfe6d1
2146c78
8fe6675
31825a5
589ecf8
f9f9d4b
c448fbe
2625939
a13ea5c
f97c071
48a719d
90052cf
4020091
e9565b2
8c78cc4
55b1f22
4d7f3fd
4f59ef7
8c4bca8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
# A part of NonVisual Desktop Access (NVDA) | ||
# Copyright (C) 2012-2025 Rui Batista, NV Access Limited, Noelia Ruiz Martínez, Joseph Lee, Babbage B.V., | ||
# Arnold Loubriat, Łukasz Golonka, Leonard de Ruijter, Julien Cochuyt, Cyrille Bougot | ||
# This file is covered by the GNU General Public License. | ||
# See the file COPYING for more details. | ||
|
||
import os | ||
import zipfile | ||
from typing import Optional | ||
|
||
import winKernel | ||
|
||
from .addonBase import AddonBase, AddonError | ||
from .AddonManifest import MANIFEST_FILENAME, AddonManifest, _report_manifest_errors, _translatedManifestPaths | ||
|
||
BUNDLE_EXTENSION = "nvda-addon" | ||
|
||
|
||
class AddonBundle(AddonBase): | ||
"""Represents the contents of an NVDA addon suitable for distribution. | ||
The bundle is compressed using the zip file format. Manifest information | ||
is available without the need for extraction.""" | ||
|
||
def __init__(self, bundlePath: str): | ||
"""Constructs an AddonBundle from a filename. | ||
|
||
:param bundlePath: The path for the bundle file. | ||
""" | ||
self._installExceptions: list[Exception] = [] | ||
"""Exceptions thrown during the installation process.""" | ||
|
||
self._path = bundlePath | ||
# Read manifest: | ||
translatedInput = None | ||
try: | ||
z = zipfile.ZipFile(self._path, "r") | ||
except (zipfile.BadZipfile, FileNotFoundError) as e: | ||
raise AddonError(f"Invalid bundle file: {self._path}") from e | ||
with z: | ||
for translationPath in _translatedManifestPaths(forBundle=True): | ||
try: | ||
# ZipFile.open opens every file in binary mode. | ||
# decoding is handled by configobj. | ||
translatedInput = z.open(translationPath, "r") | ||
break | ||
except KeyError: | ||
pass | ||
self._manifest = AddonManifest( | ||
# ZipFile.open opens every file in binary mode. | ||
# decoding is handled by configobj. | ||
z.open(MANIFEST_FILENAME, "r"), | ||
translatedInput=translatedInput, | ||
) | ||
if self.manifest.errors is not None: | ||
_report_manifest_errors(self.manifest) | ||
raise AddonError("Manifest file has errors.") | ||
|
||
def extract(self, addonPath: Optional[str] = None): | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Extracts the bundle content to the specified path. | ||
|
||
The addon will be extracted to the specified addonPath. | ||
|
||
:param addonPath: Path where to extract contents. If None, uses pendingInstallPath. | ||
""" | ||
if addonPath is None: | ||
addonPath = self.pendingInstallPath | ||
|
||
with zipfile.ZipFile(self._path, "r") as z: | ||
for info in z.infolist(): | ||
if isinstance(info.filename, bytes): | ||
# #2505: Handle non-Unicode file names. | ||
# Most archivers seem to use the local OEM code page, even though the spec says only cp437. | ||
# HACK: Overriding info.filename is a bit ugly, but it avoids a lot of code duplication. | ||
info.filename = info.filename.decode("cp%d" % winKernel.kernel32.GetOEMCP()) | ||
z.extract(info, addonPath) | ||
|
||
@property | ||
def manifest(self) -> "AddonManifest": | ||
"""Gets the manifest for the represented Addon. | ||
|
||
:return: The addon manifest. | ||
""" | ||
return self._manifest | ||
|
||
def __repr__(self): | ||
return "<AddonBundle at %s>" % self._path | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
def createAddonBundleFromPath(path, destDir=None): | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"""Creates a bundle from a directory that contains an addon manifest file. | ||
|
||
:param path: Path to the directory containing the addon. | ||
:param destDir: Directory where the bundle should be created. If None, uses the parent directory of path. | ||
:return: The created AddonBundle. | ||
:raises AddonError: If the manifest file is missing or has errors. | ||
""" | ||
basedir = path | ||
# If caller did not provide a destination directory name | ||
# Put the bundle at the same level as the add-on's top-level directory, | ||
# That is, basedir/.. | ||
if destDir is None: | ||
destDir = os.path.dirname(basedir) | ||
manifest_path = os.path.join(basedir, MANIFEST_FILENAME) | ||
if not os.path.isfile(manifest_path): | ||
raise AddonError("Can't find %s manifest file." % manifest_path) | ||
with open(manifest_path, "rb") as f: | ||
manifest = AddonManifest(f) | ||
if manifest.errors is not None: | ||
_report_manifest_errors(manifest) | ||
raise AddonError("Manifest file has errors.") | ||
bundleFilename = "%s-%s.%s" % (manifest["name"], manifest["version"], BUNDLE_EXTENSION) | ||
bundleDestination = os.path.join(destDir, bundleFilename) | ||
with zipfile.ZipFile(bundleDestination, "w") as z: | ||
# FIXME: the include/exclude feature may or may not be useful. Also python files can be pre-compiled. | ||
for dir, dirnames, filenames in os.walk(basedir): | ||
relativePath = os.path.relpath(dir, basedir) | ||
for filename in filenames: | ||
pathInBundle = os.path.join(relativePath, filename) | ||
absPath = os.path.join(dir, filename) | ||
z.write(absPath, pathInBundle) | ||
return AddonBundle(bundleDestination) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
# A part of NonVisual Desktop Access (NVDA) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please use lowerCamelCase for file names i.e. |
||
# Copyright (C) 2012-2025 Rui Batista, NV Access Limited, Noelia Ruiz Martínez, Joseph Lee, Babbage B.V., | ||
# Arnold Loubriat, Łukasz Golonka, Leonard de Ruijter, Julien Cochuyt, Cyrille Bougot | ||
# This file is covered by the GNU General Public License. | ||
# See the file COPYING for more details. | ||
|
||
import os | ||
from io import StringIO | ||
from typing import Tuple | ||
|
||
import addonAPIVersion | ||
import languageHandler | ||
from configobj import ConfigObj | ||
from configobj.validate import Validator | ||
from logHandler import log | ||
from six import string_types | ||
|
||
|
||
class AddonManifest(ConfigObj): | ||
"""Add-on manifest file. It contains metadata about an NVDA add-on package.""" | ||
|
||
configspec = ConfigObj( | ||
StringIO( | ||
""" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could we perhaps move this to a separate ini file that is read from at run time? |
||
# NVDA Add-on Manifest configuration specification | ||
# Add-on unique name | ||
# Suggested convention is lowerCamelCase. | ||
name = string() | ||
|
||
# short summary (label) of the add-on to show to users. | ||
summary = string() | ||
|
||
# Long description with further information and instructions | ||
description = string(default=None) | ||
|
||
# Name of the author or entity that created the add-on | ||
author = string() | ||
|
||
# Version of the add-on. | ||
# Suggested convention is <major>.<minor>.<patch> format. | ||
version = string() | ||
|
||
# The minimum required NVDA version for this add-on to work correctly. | ||
# Should be less than or equal to lastTestedNVDAVersion | ||
minimumNVDAVersion = apiVersion(default="0.0.0") | ||
|
||
# Must be greater than or equal to minimumNVDAVersion | ||
lastTestedNVDAVersion = apiVersion(default="0.0.0") | ||
|
||
# URL for more information about the add-on, e.g. a homepage. | ||
# Should begin with https:// | ||
url = string(default=None) | ||
|
||
# Name of default documentation file for the add-on. | ||
docFileName = string(default=None) | ||
|
||
# Custom braille tables | ||
[brailleTables] | ||
# The key is the table file name (not the full path) | ||
[[__many__]] | ||
displayName = string() | ||
contracted = boolean(default=false) | ||
input = boolean(default=true) | ||
output = boolean(default=true) | ||
|
||
# Symbol Pronunciation | ||
[symbolDictionaries] | ||
# The key is the symbol dictionary file name (not the full path) | ||
[[__many__]] | ||
displayName = string() | ||
mandatory = boolean(default=false) | ||
|
||
# NOTE: apiVersion: | ||
# EG: 2019.1.0 or 0.0.0 | ||
# Must have 3 integers separated by dots. | ||
# The first integer must be a Year (4 characters) | ||
# "0.0.0" is also valid. | ||
# The final integer can be left out, and in that case will default to 0. E.g. 2019.1 | ||
|
||
""", | ||
), | ||
) | ||
|
||
def __init__(self, input, translatedInput=None): | ||
"""Constructs an L{AddonManifest} instance from manifest string data | ||
@param input: data to read the manifest information | ||
@type input: a fie-like object. | ||
@param translatedInput: translated manifest input | ||
@type translatedInput: file-like object | ||
""" | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
super().__init__(input, configspec=self.configspec, encoding="utf-8", default_encoding="utf-8") | ||
self._errors = None | ||
val = Validator({"apiVersion": validate_apiVersionString}) | ||
result = self.validate(val, copy=True, preserve_errors=True) | ||
if result != True: # noqa: E712 | ||
self._errors = result | ||
elif True != self._validateApiVersionRange(): # noqa: E712 | ||
self._errors = "Constraint not met: minimumNVDAVersion ({}) <= lastTestedNVDAVersion ({})".format( | ||
self.get("minimumNVDAVersion"), | ||
self.get("lastTestedNVDAVersion"), | ||
) | ||
self._translatedConfig = None | ||
if translatedInput is not None: | ||
self._translatedConfig = ConfigObj(translatedInput, encoding="utf-8", default_encoding="utf-8") | ||
for k in ("summary", "description"): | ||
val = self._translatedConfig.get(k) | ||
if val: | ||
self[k] = val | ||
for fileName, tableConfig in self._translatedConfig.get("brailleTables", {}).items(): | ||
value = tableConfig.get("displayName") | ||
if value: | ||
self["brailleTables"][fileName]["displayName"] = value | ||
for fileName, dictConfig in self._translatedConfig.get("symbolDictionaries", {}).items(): | ||
value = dictConfig.get("displayName") | ||
if value: | ||
self["symbolDictionaries"][fileName]["displayName"] = value | ||
|
||
@property | ||
def errors(self): | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return self._errors | ||
|
||
def _validateApiVersionRange(self): | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
lastTested = self.get("lastTestedNVDAVersion") | ||
minRequiredVersion = self.get("minimumNVDAVersion") | ||
return minRequiredVersion <= lastTested | ||
|
||
|
||
def _report_manifest_errors(manifest): | ||
log.warning("Error loading manifest:\n%s", manifest.errors) | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
def validate_apiVersionString(value: str) -> Tuple[int, int, int]: | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
@raises: configobj.validate.ValidateError on validation error | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
""" | ||
from configobj.validate import ValidateError | ||
|
||
if not value or value == "None": | ||
return (0, 0, 0) | ||
if not isinstance(value, string_types): | ||
raise ValidateError('Expected an apiVersion in the form of a string. EG "2019.1.0"') | ||
try: | ||
return addonAPIVersion.getAPIVersionTupleFromString(value) | ||
except ValueError as e: | ||
raise ValidateError('"{}" is not a valid API Version string: {}'.format(value, e)) | ||
|
||
|
||
MANIFEST_FILENAME = "manifest.ini" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. could this be moved to the top of the file? |
||
|
||
|
||
def _translatedManifestPaths(lang=None, forBundle=False): | ||
seanbudd marked this conversation as resolved.
Show resolved
Hide resolved
|
||
if lang is None: | ||
lang = languageHandler.getLanguage() # can't rely on default keyword arguments here. | ||
langs = [lang] | ||
if "_" in lang: | ||
langs.append(lang.split("_")[0]) | ||
if lang != "en" and not lang.startswith("en_"): | ||
langs.append("en") | ||
sep = "/" if forBundle else os.path.sep | ||
return [sep.join(("locale", lang, MANIFEST_FILENAME)) for lang in langs] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
please use lowerCamelCase for file names i.e.
addonBundle