Skip to content

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

Draft
wants to merge 21 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 19 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions source/addonHandler/AddonBundle.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
# A part of NonVisual Desktop Access (NVDA)
Copy link
Member

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

# 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):
"""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


def createAddonBundleFromPath(path, destDir=None):
"""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)
160 changes: 160 additions & 0 deletions source/addonHandler/AddonManifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# A part of NonVisual Desktop Access (NVDA)
Copy link
Member

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. addonManifest

# 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(
"""
Copy link
Member

Choose a reason for hiding this comment

The 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
"""
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):
return self._errors

def _validateApiVersionRange(self):
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)


def validate_apiVersionString(value: str) -> Tuple[int, int, int]:
"""
@raises: configobj.validate.ValidateError on validation error
"""
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"
Copy link
Member

Choose a reason for hiding this comment

The 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):
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]
Loading