diff --git a/.gitignore b/.gitignore index a304182..0be8af1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,10 @@ addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -*.ini +manifest.ini *.mo *.pot *.py[co] *.nvda-addon .sconsign.dblite +/[0-9]*.[0-9]*.[0-9]*.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee931ed..207177d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,16 @@ - +# Copied from https://github.com/nvaccess/nvda # https://pre-commit.ci/ # Configuration for Continuous Integration service ci: - skip: [pyrightLocal] + # Pyright does not seem to work in pre-commit CI + skip: [pyright] autoupdate_schedule: monthly autoupdate_commit_msg: "Pre-commit auto-update" autofix_commit_msg: "Pre-commit auto-fix" submodules: true default_language_version: - python: python3.11 + python: python3.13 repos: - repo: https://github.com/pre-commit-ci/pre-commit-ci-config @@ -29,7 +30,7 @@ repos: hooks: # Prevents commits to certain branches - id: no-commit-to-branch - args: ["--branch", "main"] + args: ["--branch", "main", "--branch", "master", ] # Checks that large files have not been added. Default cut-off for "large" files is 500kb. - id: check-added-large-files # Checks python syntax @@ -42,26 +43,24 @@ repos: - id: debug-statements # Removes trailing whitespace. - id: trailing-whitespace - types_or: [python, markdown, toml, yaml] + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] # Ensures all files end in 1 (and only 1) newline. - id: end-of-file-fixer - types_or: [python, markdown, toml, yaml] + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] # Removes the UTF-8 BOM from files that have it. # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding - id: fix-byte-order-marker - types_or: [python, markdown, toml, yaml] + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] # Validates TOML files. - id: check-toml # Validates YAML files. - id: check-yaml - # Validates XML files. # Ensures that links to lines in files under version control point to a particular commit. - id: check-vcs-permalinks # Avoids using reserved Windows filenames. - id: check-illegal-windows-names - - repo: https://github.com/asottile/add-trailing-comma - rev: v3.1.0 + rev: v3.2.0 hooks: # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, # if a trailing comma is added. @@ -70,7 +69,7 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Matches Ruff version in pyproject. - rev: v0.8.1 + rev: v0.12.7 hooks: - id: ruff name: lint with ruff @@ -78,19 +77,11 @@ repos: - id: ruff-format name: format with ruff -- repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.394 +- repo: local hooks: - - id: pyright - alias: pyrightLocal - name: Check types with pyright -- repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.396 - hooks: - - id: pyright - alias: pyrightCI - name: Check types with pyright - # use nodejs version of pyright and install pyproject.toml for CI - additional_dependencies: [".", "pyright[nodejs]"] - stages: [manual] # Only run from CI manually + - id: pyright + name: type check with pyright + entry: uv run pyright + language: system + types: [python] diff --git a/addon/doc/ar/readme.md b/addon/doc/ar/readme.md index 1a98be7..e1db489 100644 --- a/addon/doc/ar/readme.md +++ b/addon/doc/ar/readme.md @@ -15,11 +15,11 @@ Tested on [eMule][1] 0.50a and 70b. ## الأوامر والمفاتيح المختصرة ## * control+shift+h: تحريك مؤشر الفأرة ومؤشر النظام تجاه شريط الأدوات - الرئيسي. + الرئيسي. * control+shift+t: لقراءة النافذة الحالية * control+shift+n: لتحريك مؤشر النظام إلى حقل الاسم في نافذة البحث. * control+shift+p: في نافذة البحث يحرك مؤشر الفأرة ومؤشر النظام تجاه قائمة - معطيات البحث أو خيارات حقول التحرير. + معطيات البحث أو خيارات حقول التحرير. * control+shift+b: لتحريك مؤشر النظام تجاه القائمة الموجودة بالنافذة الحالية. كالانتقال إلى قوائم نتائج البحث, أو التحميلات بنافذة نقل الملفات, وهكذا. @@ -28,13 +28,13 @@ Tested on [eMule][1] 0.50a and 70b. الخوادم المتاحة, وهكذا. * control+NVDA+f: If the caret is located in a read only edit box, opens a find dialog to use the commands for searching text available in NVDA. -* control+shift+l: لتحريك مؤشر NVDA ومؤشر الفأرة تجاه رأس القائمة الحالية. +* control+shift+l: لتحريك مؤشر NVDA ومؤشر الفأرة تجاه رأس القائمة الحالية. * control+shift+q: لقراءة أول كائن في شريط الحالة. يعطي معلومات عن أحدث أمر - تم تنفيذه. + تم تنفيذه. * control+shift+w: لقراءة الكائن الثاني في شريط الحالة. ويحتوي على معلومات - عن الملفات والمستخدمين الحاليين على الخادم الحالي. + عن الملفات والمستخدمين الحاليين على الخادم الحالي. * control+shift+e: لقراءة الكائن الثالث في شريط الحالة. مفيد في معرفة سرعة - الرفع والتنزيل. + الرفع والتنزيل. * control+shift+r: Reads The fourth object of the status bar; reports on connecting of eD2K and Kad network. * Not assigned: Toggles the usage of an alternative approach to read @@ -45,7 +45,7 @@ Tested on [eMule][1] 0.50a and 70b. إذا كنت داخل القائمة يمكنك التنقل بين الصفوف والأعمدة باستخدام alt+control مع الأسهم. وفي هذه الإضافة تتوفر أيضا مفاتيح الاختصار التالية: -* nvda+control+1-0: لقراءة الأعمدة العشر الأولى. +* nvda+control+1-0: لقراءة الأعمدة العشر الأولى. * nvda+shift+1-0: لقراءة الأعمدة من 11-20. * nvda+shift+C: لنسخ محتوى آخر عمود تمت قراءته إلى الحافظة. diff --git a/addon/doc/nl/readme.md b/addon/doc/nl/readme.md index c6e9488..f630804 100644 --- a/addon/doc/nl/readme.md +++ b/addon/doc/nl/readme.md @@ -21,7 +21,7 @@ Tested on [eMule][1] 0.50a and 70b. * control+shift+p: In het Zoekscherm, verplaatst focus en muis naar de lijst met zoekopties, of opties voor het invoerveld. * control+shift+b: Verplaatst de focus naar de lijst in het huidige - venster. Bijvoorbeeld: + venster. Bijvoorbeeld: * control+shift+o: Verplaatst de focus naar alleen-lezen invoervelden in het huidige venster. Bijvoorbeeld: via IRC ontvangen berichten, beschikbare servers, etc. diff --git a/buildVars.py b/buildVars.py index d51ea76..42b4c6c 100644 --- a/buildVars.py +++ b/buildVars.py @@ -1,63 +1,101 @@ -# -*- coding: UTF-8 -*- -import os.path # Build customizations # Change this file instead of sconstruct or manifest files, whenever possible. -# Full getext (please don't change) -_ = lambda x : x +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + # Add-on information variables -addon_info = { - # for previously unpublished addons, please follow the community guidelines at: - # https://bitbucket.org/nvdaaddonteam/todo/raw/master/guideLines.txt - # add-on Name, internal for nvda - "addon_name": "eMule", - # Add-on summary, usually the user visible name of the addon. - # Translators: Summary for this add-on to be shown on installation and add-on information. - "addon_summary": _("eMule"), +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name="eMule", + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary="eMule", # Add-on description - # Translators: Long description to be shown for this add-on on add-on information from add-ons manager - "addon_description": _("""Improves eMule's accessibility with NVDA. -eMule is a P2P program to search and share files. -You can get more information about eMule at -http://www.emule-project.net"""), + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description="""Improves eMule's accessibility with NVDA.\neMule is a P2P program to search and share files.\nYou can get more information about eMule at\nhttp://www.emule-project.net""", # version - "addon_version": "24.0.0", + addon_version="24.0.0", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("* Compatible with NVDA 2026.1."), # Author(s) - "addon_author": u"Noelia , Chris , Alberto ", + addon_author="Noelia , Chris , Alberto ", # URL for the add-on documentation support - "addon_url": "https://github.com/nvdaes/emule", + addon_url="https://github.com/nvdaes/emule", + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, # Documentation file name - "addon_docFileName": "readme.html", - # Minimum NVDA version supported (e.g. "2018.3") - "addon_minimumNVDAVersion": "2025.1", - # Last NVDA version supported/tested (e.g. "2018.4", ideally more recent than minimum version) - "addon_lastTestedNVDAVersion": "2025.2", - # Add-on update channel (default is stable or None) - "addon_updateChannel": None, -} - - - + addon_docFileName="readme.html", + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion="2026.1", + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion="2026.1", + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) # Define the python files that are the sources of your add-on. -# You can use glob expressions here, they will be expanded. -pythonSources = [os.path.join("addon", "appModules", "*.py")] +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = [] # Files that contain strings for translation. Usually your python sources -i18nSources = pythonSources + ["buildVars.py"] +i18nSources: list[str] = pythonSources + ["buildVars.py"] # Files that will be ignored when building the nvda-addon file # Paths are relative to the addon directory, not to the root directory of your addon sources. -excludedFiles = [os.path.join("appModules", "labelAutofinderCore", ".git")] +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + # Base language for the NVDA add-on -# If your add-on is written in a language other than english, modify this variable. +# If your add-on is written in a language other than english, modify this variable. # For example, set baseLanguage to "es" if your add-on is primarily written in spanish. -baseLanguage = "en" +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = "en" # Markdown extensions for add-on documentation # Most add-ons do not require additional Markdown extensions. # If you need to add support for markup such as tables, fill out the below list. # Extensions string must be of the form "markdown.extensions.extensionName" # e.g. "markdown.extensions.tables" to add tables. -markdownExtensions = [] +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not. +symbolDictionaries: SymbolDictionaries = {} diff --git a/manifest-translated.ini.tpl b/manifest-translated.ini.tpl index c06aa84..6df6d42 100644 --- a/manifest-translated.ini.tpl +++ b/manifest-translated.ini.tpl @@ -1,2 +1,3 @@ summary = "{addon_summary}" description = """{addon_description}""" +changelog = """{addon_changelog}""" diff --git a/manifest.ini.tpl b/manifest.ini.tpl index d44355d..2b7b0eb 100644 --- a/manifest.ini.tpl +++ b/manifest.ini.tpl @@ -4,6 +4,7 @@ description = """{addon_description}""" author = "{addon_author}" url = {addon_url} version = {addon_version} +changelog = """{addon_changelog}""" docFileName = {addon_docFileName} minimumNVDAVersion = {addon_minimumNVDAVersion} lastTestedNVDAVersion = {addon_lastTestedNVDAVersion} diff --git a/pyproject.toml b/pyproject.toml index a9398c3..aa8752d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,31 +1,45 @@ [build-system] -requires = ["setuptools~=72.0", "wheel"] +requires = ["setuptools~=80.9", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "eMule" +name = "addonTemplate" dynamic = ["version"] -description = "eMule add-on for NVDA" +description = "NVDA add-on template" maintainers = [ - {name = "Noelia ruiz Martínez", email = "nrm1977@gmail.com"}, + {name = "NV Access", email = "info@nvaccess.org"}, ] -requires-python = ">=3.11,<3.12" +requires-python = ">=3.13,<3.14" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", "Operating System :: Microsoft :: Windows", "Programming Language :: Python :: 3", "Topic :: Accessibility", ] readme = "readme.md" -license = {file = "LICENSE"} +license = {file = "COPYING.TXT"} dependencies = [ - "wxPython==4.2.2", + # Build add-on + "scons==4.10.1", + "Markdown==3.10", + # Translations management + "requests==2.32.5", + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.0.2", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + # Lint + "uv==0.9.11", + "ruff==0.14.5", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", ] - [project.urls] -Repository = "https://github.com/nvdaes/eMule" +Repository = "https://github.com/nvaccess/addonTemplate" [tool.ruff] line-length = 110 @@ -73,7 +87,7 @@ logger-objects = ["logHandler.log"] "sconstruct" = ["F821"] [tool.pyright] -venvPath = "../nvda/.venv" +venvPath = ".venv" venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -87,6 +101,7 @@ exclude = [ ".git", "__pycache__", ".venv", + "site_scons", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -95,6 +110,7 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", + "../nvda/source", ] # General config @@ -108,102 +124,89 @@ strictDictionaryInference = true strictSetInference = true # Compliant rules +reportAbstractUsage = true +reportArgumentType = true reportAssertAlwaysTrue = true reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true reportIncompleteStub = true -reportInconsistentOverload = true reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true reportInvalidStringEscapeSequence = true reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true -reportMissingModuleSource = true reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true reportOverlappingOverload = true +reportPossiblyUnboundVariable = true reportPrivateImportUsage = true +reportPrivateUsage = true reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true reportSelfClsParameterName = true -reportShadowedImports = true reportTypeCommentUsage = true reportTypedDictNotRequiredAccess = true -reportUndefinedVariable = true -reportUnusedExpression = true reportUnboundVariable = true +reportUndefinedVariable = true reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true reportUnnecessaryCast = true +reportUnnecessaryComparison = true reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true reportUntypedClassDecorator = true reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true reportUnusedClass = true reportUnusedCoroutine = true reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true reportDeprecated = true # Can be enabled by generating type stubs for modules via pyright CLI reportMissingTypeStubs = false -reportUnsupportedDunderAll = false -reportAbstractUsage = false -reportUntypedBaseClass = false -reportOptionalIterable = false -reportCallInDefaultInitializer = false -reportInvalidTypeArguments = false -reportUntypedNamedTuple = false -reportRedeclaration = false -reportOptionalCall = false -reportConstantRedefinition = false -reportWildcardImportFromLibrary = false -reportIncompatibleVariableOverride = false -reportInvalidTypeForm = false -reportGeneralTypeIssues = false -reportOptionalOperand = false -reportUnnecessaryComparison = false -reportFunctionMemberAccess = false -reportUnnecessaryIsInstance = false -reportUnusedFunction = false -reportImportCycles = false -reportUnusedImport = false -reportUnusedVariable = false -reportOperatorIssue = false -reportAssignmentType = false -reportReturnType = false -reportPossiblyUnboundVariable = false -reportMissingSuperCall = false -reportUninitializedInstanceVariable = false -reportUnknownLambdaType = false -reportMissingTypeArgument = false -reportImplicitStringConcatenation = false -reportIncompatibleMethodOverride = false -reportPrivateUsage = false -reportUnusedCallResult = false -reportOptionalSubscript = false -reportCallIssue = false -reportOptionalMemberAccess = false -reportImplicitOverride = false -reportIndexIssue = false -reportAttributeAccessIssue = false -reportArgumentType = false -reportUnknownParameterType = false -reportMissingParameterType = false -reportUnknownVariableType = false -reportUnknownArgumentType = false -reportUnknownMemberType = false - -[dependency-groups] -dev = [ - "SCons==4.8.1", - "setuptools~=72.0", -] -lint = [ - "ruff==0.8.1", - "pre-commit==4.0.1", - "pyright==1.1.396", -] - -[tool.setuptools.dynamic] -version = {attr = "buildVersion.version_detailed"} +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. diff --git a/sconstruct b/sconstruct index ab61654..481a7ac 100644 --- a/sconstruct +++ b/sconstruct @@ -1,203 +1,103 @@ -# NVDA add-on template SCONSTRUCT file -# Copyright (C) 2012-2021 Rui Batista, Noelia Martinez, Joseph Lee +# NVDA add-on template SCONSTRUCT file +# Copyright (C) 2012-2025 Rui Batista, Noelia Martinez, Joseph Lee # This file is covered by the GNU General Public License. # See the file COPYING.txt for more details. -import codecs -import gettext import os import os.path -import zipfile import sys +from pathlib import Path +from collections.abc import Iterable +from typing import Final # While names imported below are available by default in every SConscript # Linters aren't aware about them. -# To avoid Flake8 F821 warnings about them they are imported explicitly. +# To avoid PyRight `reportUndefinedVariable` errors about them they are imported explicitly. # When using other Scons functions please add them to the line below. -from SCons.Script import BoolVariable, Builder, Copy, Environment, Variables +from SCons.Script import EnsurePythonVersion, Variables, BoolVariable, Environment, Copy -sys.dont_write_bytecode = True +# Imports for type hints +from SCons.Node import FS + +# Add-on localization exchange facility and the template requires Python 3.10. +# For best practice, use Python 3.11 or later to align with NVDA development. +EnsurePythonVersion(3, 10) # Bytecode should not be written for build vars module to keep the repository root folder clean. +sys.dont_write_bytecode = True + import buildVars # NOQA: E402 -def md2html(source, dest): - import markdown - # Use extensions if defined. - mdExtensions = buildVars.markdownExtensions - lang = os.path.basename(os.path.dirname(source)).replace('_', '-') - localeLang = os.path.basename(os.path.dirname(source)) - try: - _ = gettext.translation("nvda", localedir=os.path.join("addon", "locale"), languages=[localeLang]).gettext - summary = _(buildVars.addon_info["addon_summary"]) - except Exception: - summary = buildVars.addon_info["addon_summary"] - title = "{addonSummary} {addonVersion}".format( - addonSummary=summary, addonVersion=buildVars.addon_info["addon_version"] - ) - headerDic = { - "[[!meta title=\"": "# ", - "\"]]": " #", - } - with codecs.open(source, "r", "utf-8") as f: - mdText = f.read() - for k, v in headerDic.items(): - mdText = mdText.replace(k, v, 1) - htmlText = markdown.markdown(mdText, extensions=mdExtensions) - # Optimization: build resulting HTML text in one go instead of writing parts separately. - docText = "\n".join([ - "", - "" % lang, - "", - "" - "", - "", - "%s" % title, - "\n", - htmlText, - "\n" - ]) - with codecs.open(dest, "w", "utf-8") as f: - f.write(docText) - - -def mdTool(env): - mdAction = env.Action( - lambda target, source, env: md2html(source[0].path, target[0].path), - lambda target, source, env: 'Generating % s' % target[0], - ) - mdBuilder = env.Builder( - action=mdAction, - suffix='.html', - src_suffix='.md', - ) - env['BUILDERS']['markdown'] = mdBuilder +def validateVersionNumber(key: str, val: str, _): + # Used to make sure version major.minor.patch are integers to comply with NV Access add-on store. + # Ignore all this if version number is not specified. + if val == "0.0.0": + return + versionNumber = val.split(".") + if len(versionNumber) < 3: + raise ValueError(f"{key} must have three parts (major.minor.patch)") + if not all([part.isnumeric() for part in versionNumber]): + raise ValueError(f"{key} (major.minor.patch) must be integers") + + +def expandGlobs(patterns: Iterable[str], rootdir: Path = Path(".")) -> list[FS.Entry]: + return [env.Entry(e) for pattern in patterns for e in rootdir.glob(pattern.lstrip('/'))] + + +addonDir: Final = Path("addon/") +localeDir: Final = addonDir / "locale" +docsDir: Final = addonDir / "doc" vars = Variables() vars.Add("version", "The version of this build", buildVars.addon_info["addon_version"]) +vars.Add("versionNumber", "Version number of the form major.minor.patch", "0.0.0", validateVersionNumber) vars.Add(BoolVariable("dev", "Whether this is a daily development version", False)) vars.Add("channel", "Update channel for this build", buildVars.addon_info["addon_updateChannel"]) -env = Environment(variables=vars, ENV=os.environ, tools=['gettexttool', mdTool]) -env.Append(**buildVars.addon_info) +env = Environment(variables=vars, ENV=os.environ, tools=["gettexttool", "NVDATool"]) +env.Append( + addon_info=buildVars.addon_info, + brailleTables=buildVars.brailleTables, + symbolDictionaries=buildVars.symbolDictionaries, +) if env["dev"]: - import datetime - buildDate = datetime.datetime.now() - year, month, day = str(buildDate.year), str(buildDate.month), str(buildDate.day) - env["addon_version"] = "".join([year, month.zfill(2), day.zfill(2), "-dev"]) + from datetime import date + + versionTimestamp = date.today().strftime('%Y%m%d') + version = f"{versionTimestamp}.0.0" + env["addon_info"]["addon_version"] = version + env["versionNumber"] = version env["channel"] = "dev" elif env["version"] is not None: - env["addon_version"] = env["version"] + env["addon_info"]["addon_version"] = env["version"] if "channel" in env and env["channel"] is not None: - env["addon_updateChannel"] = env["channel"] - -buildVars.addon_info["addon_version"] = env["addon_version"] -buildVars.addon_info["addon_updateChannel"] = env["addon_updateChannel"] - -addonFile = env.File("${addon_name}-${addon_version}.nvda-addon") - - -def addonGenerator(target, source, env, for_signature): - action = env.Action( - lambda target, source, env: createAddonBundleFromPath(source[0].abspath, target[0].abspath) and None, - lambda target, source, env: "Generating Addon %s" % target[0] - ) - return action + env["addon_info"]["addon_updateChannel"] = env["channel"] - -def manifestGenerator(target, source, env, for_signature): - action = env.Action( - lambda target, source, env: generateManifest(source[0].abspath, target[0].abspath) and None, - lambda target, source, env: "Generating manifest %s" % target[0] - ) - return action - - -def translatedManifestGenerator(target, source, env, for_signature): - dir = os.path.abspath(os.path.join(os.path.dirname(str(source[0])), "..")) - lang = os.path.basename(dir) - action = env.Action( - lambda target, source, env: generateTranslatedManifest(source[1].abspath, lang, target[0].abspath) and None, - lambda target, source, env: "Generating translated manifest %s" % target[0] - ) - return action - - -env['BUILDERS']['NVDAAddon'] = Builder(generator=addonGenerator) -env['BUILDERS']['NVDAManifest'] = Builder(generator=manifestGenerator) -env['BUILDERS']['NVDATranslatedManifest'] = Builder(generator=translatedManifestGenerator) - - -def createAddonHelp(dir): - docsDir = os.path.join(dir, "doc") - if os.path.isfile("style.css"): - cssPath = os.path.join(docsDir, "style.css") - cssTarget = env.Command(cssPath, "style.css", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, cssTarget) - if os.path.isfile("readme.md"): - readmePath = os.path.join(docsDir, buildVars.baseLanguage, "readme.md") - readmeTarget = env.Command(readmePath, "readme.md", Copy("$TARGET", "$SOURCE")) - env.Depends(addon, readmeTarget) +# This is necessary for further use in formatting file names. +env.Append(**env["addon_info"]) -def createAddonBundleFromPath(path, dest): - """ Creates a bundle from a directory that contains an addon manifest file.""" - basedir = os.path.abspath(path) - with zipfile.ZipFile(dest, 'w', zipfile.ZIP_DEFLATED) 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) - if pathInBundle not in buildVars.excludedFiles: - z.write(absPath, pathInBundle) - return dest - - -def generateManifest(source, dest): - addon_info = buildVars.addon_info - with codecs.open(source, "r", "utf-8") as f: - manifest_template = f.read() - manifest = manifest_template.format(**addon_info) - with codecs.open(dest, "w", "utf-8") as f: - f.write(manifest) - - -def generateTranslatedManifest(source, language, out): - _ = gettext.translation("nvda", localedir=os.path.join("addon", "locale"), languages=[language]).gettext - vars = {} - for var in ("addon_summary", "addon_description"): - vars[var] = _(buildVars.addon_info[var]) - with codecs.open(source, "r", "utf-8") as f: - manifest_template = f.read() - result = manifest_template.format(**vars) - with codecs.open(out, "w", "utf-8") as f: - f.write(result) - - -def expandGlobs(files): - return [f for pattern in files for f in env.Glob(pattern)] - - -addon = env.NVDAAddon(addonFile, env.Dir('addon')) +addonFile = env.File("${addon_name}-${addon_version}.nvda-addon") +addon = env.NVDAAddon(addonFile, env.Dir(addonDir), excludePatterns=buildVars.excludedFiles) -langDirs = [f for f in env.Glob(os.path.join("addon", "locale", "*"))] +langDirs: list[FS.Dir] = [env.Dir(d) for d in env.Glob(localeDir/"*/") if d.isdir()] # Allow all NVDA's gettext po files to be compiled in source/locale, and manifest files to be generated +moByLang: dict[str, FS.File] = {} for dir in langDirs: poFile = dir.File(os.path.join("LC_MESSAGES", "nvda.po")) - moFile = env.gettextMoFile(poFile) - env.Depends(moFile, poFile) + moTarget = env.gettextMoFile(poFile) + moFile = env.File(moTarget[0]) + moByLang[dir.name] = moFile + env.Depends(moTarget, poFile) translatedManifest = env.NVDATranslatedManifest( - dir.File("manifest.ini"), - [moFile, os.path.join("manifest-translated.ini.tpl")] + dir.File("manifest.ini"), [moFile, "manifest-translated.ini.tpl"] ) env.Depends(translatedManifest, ["buildVars.py"]) - env.Depends(addon, [translatedManifest, moFile]) + env.Depends(addon, [translatedManifest, moTarget]) pythonFiles = expandGlobs(buildVars.pythonSources) for file in pythonFiles: @@ -205,32 +105,47 @@ for file in pythonFiles: # Convert markdown files to html # We need at least doc in English and should enable the Help button for the add-on in Add-ons Manager -createAddonHelp("addon") -for mdFile in env.Glob(os.path.join('addon', 'doc', '*', '*.md')): - htmlFile = env.markdown(mdFile) +if (cssFile := Path("style.css")).is_file(): + cssPath = docsDir / cssFile + cssTarget = env.Command(str(cssPath), str(cssFile), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, cssTarget) + +if (readmeFile := Path("readme.md")).is_file(): + readmePath = docsDir / buildVars.baseLanguage / readmeFile + readmeTarget = env.Command(str(readmePath), str(readmeFile), Copy("$TARGET", "$SOURCE")) + env.Depends(addon, readmeTarget) + +for mdFile in env.Glob(docsDir/"*/*.md"): + # the title of the html file is translated based on the contents of something in the moFile for a language. + # Thus, we find the moFile for this language and depend on it if it exists. + lang = mdFile.dir.name + moFile = moByLang.get(lang) + htmlFile = env.md2html(mdFile, moFile=moFile, mdExtensions=buildVars.markdownExtensions) env.Depends(htmlFile, mdFile) + if moFile: + env.Depends(htmlFile, moFile) env.Depends(addon, htmlFile) # Pot target i18nFiles = expandGlobs(buildVars.i18nSources) -gettextvars = { - 'gettext_package_bugs_address': 'nvda-translations@groups.io', - 'gettext_package_name': buildVars.addon_info['addon_name'], - 'gettext_package_version': buildVars.addon_info['addon_version'] +gettextvars: dict[str, str] = { + "gettext_package_bugs_address": "nvda-translations@groups.io", + "gettext_package_name": buildVars.addon_info["addon_name"], + "gettext_package_version": buildVars.addon_info["addon_version"], } pot = env.gettextPotFile("${addon_name}.pot", i18nFiles, **gettextvars) -env.Alias('pot', pot) +env.Alias("pot", pot) env.Depends(pot, i18nFiles) mergePot = env.gettextMergePotFile("${addon_name}-merge.pot", i18nFiles, **gettextvars) -env.Alias('mergePot', mergePot) +env.Alias("mergePot", mergePot) env.Depends(mergePot, i18nFiles) # Generate Manifest path -manifest = env.NVDAManifest(os.path.join("addon", "manifest.ini"), os.path.join("manifest.ini.tpl")) +manifest = env.NVDAManifest(env.File(addonDir/"manifest.ini"), "manifest.ini.tpl") # Ensure manifest is rebuilt if buildVars is updated. env.Depends(manifest, "buildVars.py") env.Depends(addon, manifest) env.Default(addon) -env.Clean(addon, ['.sconsign.dblite', 'addon/doc/' + buildVars.baseLanguage + '/']) +env.Clean(addon, [".sconsign.dblite", "addon/doc/" + buildVars.baseLanguage + "/"]) diff --git a/site_scons/site_tools/NVDATool/__init__.py b/site_scons/site_tools/NVDATool/__init__.py new file mode 100644 index 0000000..ff31eec --- /dev/null +++ b/site_scons/site_tools/NVDATool/__init__.py @@ -0,0 +1,110 @@ +""" +This tool generates NVDA extensions. + +Builders: + +- NVDAAddon: Creates a .nvda-addon zip file. Requires the `excludePatterns` environment variable. +- NVDAManifest: Creates the manifest.ini file. +- NVDATranslatedManifest: Creates the manifest.ini file with only translated information. +- md2html: Build HTML from Markdown + +The following environment variables are required to create the manifest: + +- addon_info: .typing.AddonInfo +- brailleTables: .typings.BrailleTables +- symbolDictionaries: .typings.SymbolDictionaries + +The following environment variables are required to build the HTML: + +- moFile: str | pathlib.Path | None +- mdExtensions: list[str] +- addon_info: .typings.AddonInfo + +""" + +from SCons.Script import Environment, Builder + +from .addon import createAddonBundleFromPath +from .manifests import generateManifest, generateTranslatedManifest +from .docs import md2html + + +def generate(env: Environment): + env.SetDefault(excludePatterns=tuple()) + + addonAction = env.Action( + lambda target, source, env: createAddonBundleFromPath( + source[0].abspath, + target[0].abspath, + env["excludePatterns"], + ) + and None, + lambda target, source, env: f"Generating Addon {target[0]}", + ) + env["BUILDERS"]["NVDAAddon"] = Builder( + action=addonAction, + suffix=".nvda-addon", + src_suffix="/", + ) + + env.SetDefault(brailleTables={}) + env.SetDefault(symbolDictionaries={}) + + manifestAction = env.Action( + lambda target, source, env: generateManifest( + source[0].abspath, + target[0].abspath, + addon_info=env["addon_info"], + brailleTables=env["brailleTables"], + symbolDictionaries=env["symbolDictionaries"], + ) + and None, + lambda target, source, env: f"Generating manifest {target[0]}", + ) + env["BUILDERS"]["NVDAManifest"] = Builder( + action=manifestAction, + suffix=".ini", + src_siffix=".ini.tpl", + ) + + translatedManifestAction = env.Action( + lambda target, source, env: generateTranslatedManifest( + source[1].abspath, + target[0].abspath, + mo=source[0].abspath, + addon_info=env["addon_info"], + brailleTables=env["brailleTables"], + symbolDictionaries=env["symbolDictionaries"], + ) + and None, + lambda target, source, env: f"Generating translated manifest {target[0]}", + ) + + env["BUILDERS"]["NVDATranslatedManifest"] = Builder( + action=translatedManifestAction, + suffix=".ini", + src_siffix=".ini.tpl", + ) + + env.SetDefault(mdExtensions={}) + + mdAction = env.Action( + lambda target, source, env: md2html( + source[0].path, + target[0].path, + moFile=env["moFile"].path if env["moFile"] else None, + mdExtensions=env["mdExtensions"], + addon_info=env["addon_info"], + ) + and None, + lambda target, source, env: f"Generating {target[0]}", + ) + env["BUILDERS"]["md2html"] = env.Builder( + action=mdAction, + suffix=".html", + src_suffix=".md", + ) + + +def exists(): + return True diff --git a/site_scons/site_tools/NVDATool/addon.py b/site_scons/site_tools/NVDATool/addon.py new file mode 100644 index 0000000..7d67516 --- /dev/null +++ b/site_scons/site_tools/NVDATool/addon.py @@ -0,0 +1,23 @@ +import zipfile +from collections.abc import Iterable +from pathlib import Path + + +def matchesNoPatterns(path: Path, patterns: Iterable[str]) -> bool: + """Checks if the path, the first argument, does not match any of the patterns passed as the second argument.""" + return not any((path.match(pattern) for pattern in patterns)) + + +def createAddonBundleFromPath(path: str | Path, dest: str, excludePatterns: Iterable[str]): + """Creates a bundle from a directory that contains an addon manifest file.""" + if isinstance(path, str): + path = Path(path) + basedir = path.absolute() + with zipfile.ZipFile(dest, "w", zipfile.ZIP_DEFLATED) as z: + for p in basedir.rglob("*"): + if p.is_dir(): + continue + pathInBundle = p.relative_to(basedir) + if matchesNoPatterns(pathInBundle, excludePatterns): + z.write(p, pathInBundle) + return dest diff --git a/site_scons/site_tools/NVDATool/docs.py b/site_scons/site_tools/NVDATool/docs.py new file mode 100644 index 0000000..e1f80ad --- /dev/null +++ b/site_scons/site_tools/NVDATool/docs.py @@ -0,0 +1,59 @@ +import gettext +from pathlib import Path + +import markdown + +from .typings import AddonInfo + + +def md2html( + source: str | Path, + dest: str | Path, + *, + moFile: str | Path | None, + mdExtensions: list[str], + addon_info: AddonInfo, +): + if isinstance(source, str): + source = Path(source) + if isinstance(dest, str): + dest = Path(dest) + if isinstance(moFile, str): + moFile = Path(moFile) + + try: + with moFile.open("rb") as f: + _ = gettext.GNUTranslations(f).gettext + except Exception: + summary = addon_info["addon_summary"] + else: + summary = _(addon_info["addon_summary"]) + version = addon_info["addon_version"] + title = f"{summary} {version}" + lang = source.parent.name.replace("_", "-") + headerDic = { + '[[!meta title="': "# ", + '"]]': " #", + } + with source.open("r", encoding="utf-8") as f: + mdText = f.read() + for k, v in headerDic.items(): + mdText = mdText.replace(k, v, 1) + htmlText = markdown.markdown(mdText, extensions=mdExtensions) + # Optimization: build resulting HTML text in one go instead of writing parts separately. + docText = "\n".join( + ( + "", + f'', + "", + '', + '', + '', + f"{title}", + "\n", + htmlText, + "\n", + ), + ) + with dest.open("w", encoding="utf-8") as f: + f.write(docText) # type: ignore diff --git a/site_scons/site_tools/NVDATool/manifests.py b/site_scons/site_tools/NVDATool/manifests.py new file mode 100644 index 0000000..a55785e --- /dev/null +++ b/site_scons/site_tools/NVDATool/manifests.py @@ -0,0 +1,67 @@ +import codecs +import gettext +from functools import partial + +from .typings import AddonInfo, BrailleTables, SymbolDictionaries +from .utils import format_nested_section + + +def generateManifest( + source: str, + dest: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, +): + # Prepare the root manifest section + with codecs.open(source, "r", "utf-8") as f: + manifest_template = f.read() + manifest = manifest_template.format(**addon_info) + # Add additional manifest sections such as custom braile tables + # Custom braille translation tables + if brailleTables: + manifest += format_nested_section("brailleTables", brailleTables) + + # Custom speech symbol dictionaries + if symbolDictionaries: + manifest += format_nested_section("symbolDictionaries", symbolDictionaries) + + with codecs.open(dest, "w", "utf-8") as f: + f.write(manifest) + + +def generateTranslatedManifest( + source: str, + dest: str, + *, + mo: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, +): + with open(mo, "rb") as f: + _ = gettext.GNUTranslations(f).gettext + vars: dict[str, str] = {} + for var in ("addon_summary", "addon_description", "addon_changelog"): + vars[var] = _(addon_info[var]) + with codecs.open(source, "r", "utf-8") as f: + manifest_template = f.read() + manifest = manifest_template.format(**vars) + + _format_section_only_with_displayName = partial( + format_nested_section, + include_only_keys=("displayName",), + _=_, + ) + + # Add additional manifest sections such as custom braile tables + # Custom braille translation tables + if brailleTables: + manifest += _format_section_only_with_displayName("brailleTables", brailleTables) + + # Custom speech symbol dictionaries + if symbolDictionaries: + manifest += _format_section_only_with_displayName("symbolDictionaries", symbolDictionaries) + + with codecs.open(dest, "w", "utf-8") as f: + f.write(manifest) diff --git a/site_scons/site_tools/NVDATool/typings.py b/site_scons/site_tools/NVDATool/typings.py new file mode 100644 index 0000000..650a759 --- /dev/null +++ b/site_scons/site_tools/NVDATool/typings.py @@ -0,0 +1,38 @@ +from typing import TypedDict, Protocol + + +class AddonInfo(TypedDict): + addon_name: str + addon_summary: str + addon_description: str + addon_version: str + addon_changelog: str + addon_author: str + addon_url: str | None + addon_sourceURL: str | None + addon_docFileName: str + addon_minimumNVDAVersion: str | None + addon_lastTestedNVDAVersion: str | None + addon_updateChannel: str | None + addon_license: str | None + addon_licenseURL: str | None + + +class BrailleTableAttributes(TypedDict): + displayName: str + contracted: bool + output: bool + input: bool + + +class SymbolDictionaryAttributes(TypedDict): + displayName: str + mandatory: bool + + +BrailleTables = dict[str, BrailleTableAttributes] +SymbolDictionaries = dict[str, SymbolDictionaryAttributes] + + +class Strable(Protocol): + def __str__(self) -> str: ... diff --git a/site_scons/site_tools/NVDATool/utils.py b/site_scons/site_tools/NVDATool/utils.py new file mode 100644 index 0000000..c900841 --- /dev/null +++ b/site_scons/site_tools/NVDATool/utils.py @@ -0,0 +1,27 @@ +from collections.abc import Callable, Container, Mapping + +from .typings import Strable + + +def _(arg: str) -> str: + """ + A function that passes the string to it without doing anything to it. + Needed for recognizing strings for translation by Gettext. + """ + return arg + + +def format_nested_section( + section_name: str, + data: Mapping[str, Mapping[str, Strable]], + include_only_keys: Container[str] | None = None, + _: Callable[[str], str] = _, +) -> str: + lines = [f"\n[{section_name}]"] + for item_name, inner_dict in data.items(): + lines.append(f"[[{item_name}]]") + for key, val in inner_dict.items(): + if include_only_keys and key not in include_only_keys: + continue + lines.append(f"{key} = {_(str(val))}") + return "\n".join(lines) + "\n"