From 3592bd85e93b1cd2fefec7bbd9c9d3da2a05e0e6 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Jul 2026 20:48:01 +0200 Subject: [PATCH 01/32] test: add unit testing infrastructure and test suite --- .../updatingExistingAddons.md | 43 +++ pyproject.toml | 2 + tests/__init__.py | 9 + tests/test_syncAddonWithTemplate.py | 258 ++++++++++++++++++ 4 files changed, 312 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_syncAddonWithTemplate.py diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4895903..a59e0ba 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -245,3 +245,46 @@ If you committed changes, you can use: ```sh git reset --hard {cleanBranch} ``` +--- + +## Unit Testing the Add-on + +Ensuring your add-on behavior remains consistent during development and following template updates is done through unit testing. + +For the unit tests to execute successfully, the target module (such as `syncAddonWithTemplate.py`) must be located at the root of the repository as a sibling file to the `tests/` directory (at the same hierarchical level). This ensures that Python's module discovery can properly import the script when `unittest` runs from the project root. + +They can be validated quickly using `unittest` inside the virtual environment managed by `uv`. + +### Running the Test Suite + +To run the entire unit test suite with automatic test discovery and detailed output for every executed test case, use: + +```sh +uv run python -m unittest discover -s tests -v +``` + +Here is what each part of the command does: +* `uv run`: Executes the command within the virtual environment managed by `uv`. +* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. +* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. +* `-v`: Enables verbose output, displaying the status and description of each test method individually. + +To run a specific test module individually during development, specify its path: + +```sh +uv run python -m unittest -v tests/test_syncAddonWithTemplate.py +``` + +--- + +### Unit Test Overview (`test_syncAddonWithTemplate.py`) + +The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST-based config merges, file parsing, and formatting behave predictably across project updates: + +* **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. +* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs. +* **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. +* **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. +* **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. +* **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. +* **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). diff --git a/pyproject.toml b/pyproject.toml index 8766194..b0d1008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,6 +80,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", + "tests", ] [tool.ruff.format] @@ -118,6 +119,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "tests", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ae5710c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Unit test suite for the NVDA Add-on update and synchronization tools. + +This package contains automated tests to verify the AST-based configuration merges, +TOML updates, dependency parsing, and safety backup mechanisms of the update script. +""" diff --git a/tests/test_syncAddonWithTemplate.py b/tests/test_syncAddonWithTemplate.py new file mode 100644 index 0000000..14e8101 --- /dev/null +++ b/tests/test_syncAddonWithTemplate.py @@ -0,0 +1,258 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import tempfile +import unittest +from pathlib import Path + +# Import functions to test +from syncAddonWithTemplate import ( + extractBuildvarsMetadata, + fixTomlIndentation, + formatAuthorList, + mergeBuildvarsFile, + mergePyprojectToml, + mergeDependencyLists, +) + + +class TestSyncAddonWithTemplate(unittest.TestCase): + """Unit test suite for syncAddonWithTemplate.py module.""" + + def testFixTomlIndentation(self) -> None: + """Ensure that 4 spaces are replaced by a tab inside maintainers/authors blocks only.""" + inputToml = ( + 'name = "myAddon"\n' + "maintainers = [\n" + ' {name = "John Doe", email = "john@example.com"},\n' + "]\n" + "otherSection = {\n" + ' key = "value"\n' + "}\n" + ) + expectedOutput = ( + 'name = "myAddon"\n' + "maintainers = [\n" + '\t{name = "John Doe", email = "john@example.com"},\n' + "]\n" + "otherSection = {\n" + ' key = "value"\n' + "}\n" + ) + + result = fixTomlIndentation(inputToml) + self.assertEqual(result, expectedOutput) + + def testFormatAuthorList(self) -> None: + """Ensure raw author string parsing produces a formatted tomlkit array.""" + rawAuthors = "John Doe , Jane Smith" + authorsArray = formatAuthorList(rawAuthors) + + self.assertEqual(len(authorsArray), 2) + self.assertEqual(authorsArray[0]["name"], "John Doe") + self.assertEqual(authorsArray[0]["email"], "john@example.com") + self.assertEqual(authorsArray[1]["name"], "Jane Smith") + self.assertEqual(authorsArray[1]["email"], "") + + def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: + """Ensure legacy buildVars.py is correctly merged into the latest official template structure.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # 1. Legacy dictionary-based buildVars.py + projBvPath.write_text( + 'addon_info = {\n' + ' "addon_name": "dayOfTheWeek",\n' + ' "addon_summary": _("Day of the week"),\n' + ' "addon_version": "20251022.0.1",\n' + '}\n' + 'import os\n' + 'pythonSources = [os.path.join("addon", "globalPlugins", "*.py")]\n' + 'i18nSources = pythonSources + ["buildVars.py"]\n' + 'excludedFiles = []\n' + 'baseLanguage = "en"\n' + 'markdownExtensions = []\n', + encoding="utf-8", + ) + + # 2. Official template buildVars.py content + tplBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="addonTemplate",\n' + ' addon_summary=_("Add-on user visible name"),\n' + ' addon_description=_("""Description."""),\n' + ' addon_version="x.y",\n' + ' addon_changelog=_("""Changelog."""),\n' + ' addon_author="name ",\n' + ' addon_url=None,\n' + ' addon_sourceURL=None,\n' + ' addon_docFileName="readme.html",\n' + ' addon_minimumNVDAVersion=None,\n' + ' addon_lastTestedNVDAVersion=None,\n' + ' addon_updateChannel=None,\n' + ' addon_license=None,\n' + ' addon_licenseURL=None,\n' + ')\n\n' + 'pythonSources: list[str] = []\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n' + 'speechDictionaries: SpeechDictionaries = {}\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content = projBvPath.read_text(encoding="utf-8") + # Verify legacy metadata mapping (handling single quote formatting) + self.assertIn("addon_name='dayOfTheWeek'", content) + self.assertIn("addon_version='20251022.0.1'", content) + # Verify new official template imports and variables + self.assertIn("from site_scons.site_tools.NVDATool.utils import _", content) + self.assertIn("brailleTables: BrailleTables = {}", content) + self.assertIn("symbolDictionaries: SymbolDictionaries = {}", content) + self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + + def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: + """Ensure modern buildVars.py gets missing speechDictionaries injected from official template.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # 1. Modern buildVars.py without speechDictionaries + projBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="dayOfTheWeek",\n' + ' addon_summary=_("Day of the week"),\n' + ' addon_version="20260222.0.0",\n' + ')\n\n' + 'import os\n' + 'pythonSources: list[str] = [os.path.join("addon", "globalPlugins", "*.py")]\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n', + encoding="utf-8", + ) + + # 2. Official template buildVars.py + tplBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="addonTemplate",\n' + ' addon_summary=_("Add-on user visible name"),\n' + ' addon_version="x.y",\n' + ')\n\n' + 'pythonSources: list[str] = []\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n' + 'speechDictionaries: SpeechDictionaries = {}\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name='dayOfTheWeek'", content) + self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + + def testMergeDependencyLists(self) -> None: + """Ensure dependency lists merge updates existing package versions while preserving custom ones.""" + projDeps = ["pyright>=1.1.0", "requests>=2.28.0", "ruff==0.1.0"] + tplDeps = ["pyright>=1.2.0", "ruff==0.2.0", "pytest"] + + merged = mergeDependencyLists(projDeps, tplDeps) + + # Check that versions from template override project versions + self.assertIn("pyright>=1.2.0", merged) + self.assertNotIn("pyright>=1.1.0", merged) + self.assertIn("ruff==0.2.0", merged) + + # Check that custom dependency is preserved + self.assertIn("requests>=2.28.0", merged) + + # Check that new template dependency is added + self.assertIn("pytest", merged) + + def testMergePyprojectTomlIntelligent(self) -> None: + """Ensure pyproject.toml is intelligently merged without duplicating dependencies.""" + with tempfile.TemporaryDirectory() as tempDir: + projToml = Path(tempDir) / "pyproject.toml" + tplToml = Path(tempDir) / "template_pyproject.toml" + + projToml.write_text( + '[project]\n' + 'name = "myAddon"\n' + 'dependencies = ["requests>=2.0.0", "pyright>=1.0.0"]\n', + encoding="utf-8", + ) + + tplToml.write_text( + '[project]\n' + 'name = "addonTemplate"\n' + 'dependencies = ["pyright>=2.0.0", "ruff"]\n' + '[dependency-groups]\n' + 'dev = ["pytest"]\n', + encoding="utf-8", + ) + + status = mergePyprojectToml(projToml, tplToml, metadata={}, dryRun=False) + self.assertEqual(status, "merged intelligently (tomlkit)") + + content = projToml.read_text(encoding="utf-8") + self.assertIn('name = "myAddon"', content) + self.assertIn('requests>=2.0.0', content) + + def testMergeBuildvarsAutoImportsOs(self) -> None: + """Ensure 'import os' is automatically added if merged buildVars uses the os module.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # Legacy buildVars using os module without import in template + projBvPath.write_text( + 'import os\n' + 'pythonSources = [os.path.join("addon", "*.py")]\n', + encoding="utf-8", + ) + + tplBvPath.write_text( + 'pythonSources: list[str] = []\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) + + content = projBvPath.read_text(encoding="utf-8") + self.assertTrue(content.startswith("import os\n")) + + +if __name__ == "__main__": + unittest.main() From 98ae0edaa2ace9ec18b35632e37c1ef0af745636 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 9 Jul 2026 16:41:38 +0200 Subject: [PATCH 02/32] - Introduce updateAddon.py at the repository root to automate configuration merges for legacy add-ons. - Add tomlkit to project dependencies in pyproject.toml to support robust AST-aware TOML parsing. - Update updatingExistingAddons.md to provide clear instructions and prerequisites for the new automated tool. - Exclude the update script from ruff and pyright configurations to prevent strict environment linting conflicts. --- pyproject.toml | 10 +- updateAddon.py | 418 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 updateAddon.py diff --git a/pyproject.toml b/pyproject.toml index b0d1008..05f3347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,11 @@ Repository = "https://github.com/nvaccess/addonTemplate" # PEP 735 dependency groups. `uv sync` installs the `dev` group by default, which # pulls in every tool needed to build, translate, and lint the add-on. [dependency-groups] -# Build add-on +# Build add-on & repository synchronization machinery build = [ "scons==4.10.1", "Markdown==3.10", + "tomlkit==0.15.0", ] # Translations management l10n = [ @@ -48,7 +49,7 @@ lint = [ "uv==0.11.15", "ruff==0.14.5", "prek==0.4.8", - "pyright[nodejs]==1.1.407", + "pyright[nodejs]==1.1.411", ] dev = [ { include-group = "build" }, @@ -80,6 +81,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", + "syncAddonWithTemplate.py", "tests", ] @@ -119,6 +121,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "syncAddonWithTemplate.py", "tests", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, @@ -228,3 +231,6 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. + +[tool.setuptools] +py-modules = [] diff --git a/updateAddon.py b/updateAddon.py new file mode 100644 index 0000000..cce5cca --- /dev/null +++ b/updateAddon.py @@ -0,0 +1,418 @@ +#!/usr/bin/env python3 +"""Main update engine using AST tracking and structure alignment.""" + +import argparse +import ast +import os +import shutil +import subprocess +import sys +import tempfile + +# Built-in in Python 3.11+, fully standardized for Python 3.13 +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import tomlkit + +TOMLKIT_AVAILABLE: bool = True + + +def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: + """Recursively merges dictTpl into dictProj. Supports both dict and tomlkit container types.""" + for key, value in dictTpl.items(): + if key in dictProj: + if isinstance(dictProj[key], dict) and isinstance(value, dict): + deepMergeDicts(dictProj[key], value) + elif isinstance(dictProj[key], list) and isinstance(value, list): + for item in value: + if item not in dictProj[key]: + dictProj[key].append(item) + else: + pass + else: + dictProj[key] = value + return dictProj + + +def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict[str, Any]]: + """Extract metadata from an old buildVars.py file safely using modern AST APIs.""" + p = Path(filePath) + if not p.exists(): + return {}, {} + + with p.open("r", encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError as e: + print(f"[-] Syntax error while reading {p}: {e}") + return {}, {} + + metadata: dict[str, Any] = {} + globalVars: dict[str, Any] = {} + topLevelVars = { + "pythonSources", + "excludedFiles", + "baseLanguage", + "markdownExtensions", + "brailleTables", + "symbolDictionaries", + "speechDictionaries", + } + + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if not isinstance(target, ast.Name): + continue + varName = target.id + + if varName == "addon_info": + if isinstance(node.value, ast.Dict): + for keyNode, valNode in zip(node.value.keys, node.value.values): + if keyNode is None: + continue + key = getattr(keyNode, "value", None) + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val = getattr(valNode, "value", None) + if key is not None: + metadata[key] = val + elif isinstance(node.value, ast.Call) and getattr(node.value.func, "id", None) == "AddonInfo": + for keyword in node.value.keywords: + key = keyword.arg + valNode = keyword.value + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val = getattr(valNode, "value", None) + if key is not None: + metadata[key] = val + elif varName in topLevelVars: + globalVars[varName] = ast.unparse(node.value) + elif isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name) and node.target.id in topLevelVars: + if node.value is not None: + globalVars[node.target.id] = ast.unparse(node.value) + + return metadata, globalVars + + +def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = False) -> str: + """Merge template pyproject.toml configuration into the developer's file.""" + pTpl = Path(tplPath) + pProj = Path(projPath) + + if not pTpl.exists(): + return "skipped (no template)" + + if not pProj.exists(): + if not dryRun: + shutil.copy2(pTpl, pProj) + return "created from template" + + try: + with pProj.open("r", encoding="utf-8") as f: + projData = tomlkit.parse(f.read()) + with pTpl.open("r", encoding="utf-8") as f: + tplData = tomlkit.parse(f.read()) + + # Check if NV Access was ALREADY the original author/maintainer of the project + was_originally_nvaccess = False + if "project" in projData: + for field in ["authors", "maintainers"]: + if field in projData["project"] and isinstance(projData["project"][field], list): + for item in projData["project"][field]: + name = item.get("name", "") if hasattr(item, "get") else "" + if not name and isinstance(item, dict): + name = item.get("name", "") + if str(name).strip().lower() in ["nv access", "nvaccess"]: + was_originally_nvaccess = True + break + + # Backup dependencies from project to merge them manually later + proj_deps = [] + if "project" in projData and "dependencies" in projData["project"]: + proj_deps = list(projData["project"]["dependencies"]) + # Temporarily remove dependencies from project to let template comments win + del projData["project"]["dependencies"] + + # Execute the main structural merge + mergedData = deepMergeDicts(cast(dict[str, Any], projData), cast(dict[str, Any], tplData)) + + if "project" in mergedData: + project_section = mergedData["project"] + + # 1. Conditional cleanup of NV Access placeholders + # Only remove them if NV Access wasn't the original author of the add-on + if not was_originally_nvaccess: + for field in ["authors", "maintainers"]: + if field in project_section and isinstance(project_section[field], list): + toml_list = project_section[field] + # Reverse loop to safely delete by index within tomlkit structure + for i in range(len(toml_list) - 1, -1, -1): + item = toml_list[i] + name = item.get("name", "") if hasattr(item, "get") else "" + if not name and isinstance(item, dict): + name = item.get("name", "") + + if str(name).strip().lower() in ["nv access", "nvaccess"]: + toml_list.pop(i) + + # 2. Smart merge of dependencies (Template layout and comments win) + if "dependencies" in project_section: + tpl_deps = project_section["dependencies"] + + # Extract base package name (e.g. 'pyright' from 'pyright[nodejs]==1.1.407') + def get_base(s: str) -> str: + return s.split("[")[0].split("=")[0].split(">")[0].strip().lower() + + tpl_bases = {get_base(d) for d in tpl_deps} + + # Append custom user dependencies only if they are not already defined by the template + for dep in proj_deps: + base = get_base(dep) + if base not in tpl_bases: + tpl_deps.append(dep) + + if not dryRun: + with pProj.open("w", encoding="utf-8") as f: + f.write(tomlkit.dumps(cast(tomlkit.TOMLDocument, mergedData))) + return "merged intelligently (tomlkit)" + except Exception as e: + return f"failed to merge ({str(e)})" + + +def mergeBuildvarsFile( + projPath: str | Path, + tplPath: str | Path, + metadata: dict[str, Any], + globalVars: dict[str, Any], + dryRun: bool = False, +) -> str: + """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks.""" + pTpl = Path(tplPath) + pProj = Path(projPath) + + if not pTpl.exists(): + return "failed (no template found)" + + with pTpl.open("r", encoding="utf-8") as f: + tplContent = f.read() + + try: + tree = ast.parse(tplContent) + except SyntaxError as e: + return f"failed (template syntax error: {e})" + + tplLines = tplContent.splitlines(keepends=True) + replacements: dict[tuple[int, int], str] = {} + + for node in ast.walk(tree): + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "AddonInfo": + for kw in node.keywords: + if kw.arg in metadata: + key = kw.arg + val = metadata[key] + if val is None: + formattedVal = "None" + elif isinstance(val, str): + is_multiline = key in ["addon_summary", "addon_description", "addon_changelog"] + formattedVal = f'_("""{val}""")' if is_multiline else f'"{val}"' + else: + formattedVal = str(val) + + if kw.end_lineno is not None: + line_content = tplLines[kw.lineno - 1] + indent = line_content[: len(line_content) - len(line_content.lstrip())] + replacements[(kw.lineno - 1, kw.end_lineno)] = f"{indent}{key}={formattedVal},\n" + + elif isinstance(node, ast.Assign) and len(node.targets) == 1: + target = node.targets[0] + if isinstance(target, ast.Name) and target.id in globalVars: + key = target.id + valExpression = globalVars[key] + if node.end_lineno is not None: + line_content = tplLines[node.lineno - 1] + indent = line_content[: len(line_content) - len(line_content.lstrip())] + prefix = f"{indent}import os\n" if "os." in valExpression else "" + replacements[(node.lineno - 1, node.end_lineno)] = ( + f"{prefix}{indent}{key} = {valExpression}\n" + ) + + elif isinstance(node, ast.AnnAssign): + if isinstance(node.target, ast.Name) and node.target.id in globalVars: + key = node.target.id + valExpression = globalVars[key] + if node.end_lineno is not None: + line_content = tplLines[node.lineno - 1] + indent = line_content[: len(line_content) - len(line_content.lstrip())] + typeStr = ast.unparse(node.annotation) + prefix = f"{indent}import os\n" if "os." in valExpression else "" + replacements[(node.lineno - 1, node.end_lineno)] = ( + f"{prefix}{indent}{key}: {typeStr} = {valExpression}\n" + ) + + sortedRanges = sorted(replacements.keys(), key=lambda x: x[0], reverse=True) + for start, end in sortedRanges: + tplLines[start:end] = [replacements[(start, end)]] + + if not dryRun: + with pProj.open("w", encoding="utf-8") as f: + f.writelines(tplLines) + return "merged & structured (AST verified)" + + +def main() -> None: + """Execute main CLI entry point for the NVDA Add-on update tool.""" + parser = argparse.ArgumentParser( + description="Non-destructive industrial update tool for NVDA Add-ons.", + ) + parser.add_argument( + "addonDir", + nargs="?", + default=None, + help="Path to the root directory of the add-on to update (defaults to current directory).", + ) + parser.add_argument( + "--dry-run", + dest="dryRun", + action="store_true", + help="Simulate execution without modifying any files.", + ) + parser.add_argument( + "--skip-backup", + dest="skipBackup", + action="store_true", + help="Disable safety automatic project backup.", + ) + args = parser.parse_args() + + addonDirInput = args.addonDir if args.addonDir else os.getcwd() + addonDir = os.path.abspath(addonDirInput) + + print("=== NVDA ADD-ON UPDATE TOOL ===") + print(f"[*] Target Directory: {addonDir}") + + oldBuildvars = os.path.join(addonDir, "buildVars.py") + oldPyproject = os.path.join(addonDir, "pyproject.toml") + + if not os.path.exists(oldBuildvars): + print(f"[-] Error: '{addonDir}' does not appear to be a valid NVDA Add-on (missing buildVars.py).") + input("\nPress Enter to exit...") + sys.exit(1) + + print("[*] Phase 1: Analyzing existing project structure and metadata...") + bvMeta, bvGlobals = extractBuildvarsMetadata(oldBuildvars) + addonName = bvMeta.get("addon_name", os.path.basename(addonDir)) + print(f"[+] Target Add-on Identified: {addonName}") + + if args.dryRun: + print("[!] RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") + + if not args.skipBackup and not args.dryRun: + backupDir = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + print(f"[*] Phase 2: Creating safety automatic backup in: {os.path.basename(backupDir)}...") + try: + shutil.copytree( + addonDir, + backupDir, + ignore=shutil.ignore_patterns(".git", "__pycache__", ".venv", "*_bak_*"), + ) + print("[+] Backup created successfully.") + except Exception as e: + print(f"[-] Critical: Backup failed ({e}). Aborting update.") + input("\nPress Enter to exit...") + sys.exit(1) + else: + print("[*] Phase 2: Safety backup skipped.") + + print("[*] Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + + with tempfile.TemporaryDirectory() as tempDir: + print("[*] Cloning template into temporary workspace...") + templateUrl = "https://github.com/nvaccess/AddonTemplate.git" + + try: + subprocess.run( + ["git", "clone", "--depth", "1", templateUrl, tempDir], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + print("[+] Template cloned successfully.") + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print("[-] Error: Failed to execute git clone. Make sure Git is available in your PATH.") + if isinstance(e, subprocess.CalledProcessError) and e.stderr: + print(f"Details: {e.stderr.decode('utf-8', errors='ignore')}") + input("\nPress Enter to exit...") + sys.exit(1) + + print("[*] Synchronizing template machinery files...") + protectedElements = { + "readme.md", + "changelog.md", + "addon", + ".git", + "__pycache__", + ".venv", + "docs", + ".ruff_cache", + "updateaddon.py", + } + syncReport = [] + + for item in os.listdir(tempDir): + if item.lower() in protectedElements: + syncReport.append(f"{item} .................... skipped (protected scope)") + continue + + if item in ["buildVars.py", "pyproject.toml"]: + continue + + srcItem = os.path.join(tempDir, item) + dstItem = os.path.join(addonDir, item) + + try: + if os.path.isdir(srcItem): + if not args.dryRun: + os.makedirs(dstItem, exist_ok=True) + shutil.copytree(srcItem, dstItem, dirs_exist_ok=True) + syncReport.append(f"{item}/ ................... merged safely") + else: + if not args.dryRun: + shutil.copy2(srcItem, dstItem) + syncReport.append(f"{item} .................... synchronized") + except Exception as e: + syncReport.append(f"{item} .................... failed ({str(e)})") + + print("[*] Phase 4: Processing structural configuration merges...") + templateBuildvars = os.path.join(tempDir, "buildVars.py") + templatePyproject = os.path.join(tempDir, "pyproject.toml") + + bvStatus = mergeBuildvarsFile(oldBuildvars, templateBuildvars, bvMeta, bvGlobals, args.dryRun) + ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, args.dryRun) + + print("\n" + "=" * 50) + print("UPDATE REPORT") + print("=" * 50) + print(f"Add-on ....................... {addonName}") + print("\nTemplate synchronization:") + for entry in syncReport: + print(f" - {entry}") + print( + f"\nConfiguration files:\n" + f" buildVars.py ............... {bvStatus}\n" + f" pyproject.toml ............. {ppStatus}", + ) + + if not args.dryRun: + print("\n[+] Project successfully updated. Temporary workspace destroyed.") + else: + print("\n[+] Simulation finished. Workspace cleared.") + + input("\nPress Enter to exit...") + + +if __name__ == "__main__": + main() From 8c816c52a2b26041ad3e53a393dcf3e92fc1ec64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 9 Jul 2026 17:48:23 +0200 Subject: [PATCH 03/32] Improve docstring and file header --- updateAddon.py | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/updateAddon.py b/updateAddon.py index cce5cca..2bff1aa 100644 --- a/updateAddon.py +++ b/updateAddon.py @@ -1,5 +1,6 @@ -#!/usr/bin/env python3 -"""Main update engine using AST tracking and structure alignment.""" +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. import argparse import ast @@ -20,7 +21,13 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: - """Recursively merges dictTpl into dictProj. Supports both dict and tomlkit container types.""" + """Recursively merges dictTpl into dictProj. Supports both dict and tomlkit container types. + + :param dictProj: The original dictionary to be updated. + :param dictTpl: The template dictionary whose values will be merged into dictProj. + :return: The updated dictProj with merged values from dictTpl. + """ + for key, value in dictTpl.items(): if key in dictProj: if isinstance(dictProj[key], dict) and isinstance(value, dict): @@ -37,7 +44,12 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict[str, Any]]: - """Extract metadata from an old buildVars.py file safely using modern AST APIs.""" + """Extract metadata from an old buildVars.py file safely using modern AST APIs. + + :param filePath: The path to the buildVars.py file. + :return: A tuple containing two dictionaries: metadata and globalVars. + """ + p = Path(filePath) if not p.exists(): return {}, {} @@ -99,7 +111,14 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = False) -> str: - """Merge template pyproject.toml configuration into the developer's file.""" + """Merge template pyproject.toml configuration into the developer's file. + + :param projPath: Path to the existing pyproject.toml file. + :param tplPath: Path to the template pyproject.toml file. + :param dryRun: If True, simulate the merge without writing changes to disk. + :return: A string indicating the result of the merge operation. + """ + pTpl = Path(tplPath) pProj = Path(projPath) @@ -190,7 +209,16 @@ def mergeBuildvarsFile( globalVars: dict[str, Any], dryRun: bool = False, ) -> str: - """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks.""" + """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks. + + :param projPath: Path to the existing buildVars.py file. + :param tplPath: Path to the template buildVars.py file. + :param metadata: Dictionary containing metadata values to update. + :param globalVars: Dictionary containing global variable values to update. + :param dryRun: If True, simulate the merge without writing changes to disk. + :Return: A string indicating the result of the merge operation. + """ + pTpl = Path(tplPath) pProj = Path(projPath) From c33a8dc71c22c48c4d6228d5a687ae6669f24778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 9 Jul 2026 17:51:01 +0200 Subject: [PATCH 04/32] Remove blanklines after docstrings --- updateAddon.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/updateAddon.py b/updateAddon.py index 2bff1aa..9c7a805 100644 --- a/updateAddon.py +++ b/updateAddon.py @@ -27,7 +27,6 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st :param dictTpl: The template dictionary whose values will be merged into dictProj. :return: The updated dictProj with merged values from dictTpl. """ - for key, value in dictTpl.items(): if key in dictProj: if isinstance(dictProj[key], dict) and isinstance(value, dict): @@ -49,7 +48,6 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict :param filePath: The path to the buildVars.py file. :return: A tuple containing two dictionaries: metadata and globalVars. """ - p = Path(filePath) if not p.exists(): return {}, {} @@ -118,7 +116,6 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = :param dryRun: If True, simulate the merge without writing changes to disk. :return: A string indicating the result of the merge operation. """ - pTpl = Path(tplPath) pProj = Path(projPath) @@ -218,7 +215,6 @@ def mergeBuildvarsFile( :param dryRun: If True, simulate the merge without writing changes to disk. :Return: A string indicating the result of the merge operation. """ - pTpl = Path(tplPath) pProj = Path(projPath) From f052e6c323aa9e3ab082a888f60e8f0a2a67df86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 9 Jul 2026 18:04:29 +0200 Subject: [PATCH 05/32] Fix typo --- docs/managementFromGit/updatingExistingAddons.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index a59e0ba..7e54872 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -9,7 +9,7 @@ git clone https://github.com/{repoName}.git ``` -1. In the folder where your add-on repository is cloned, create an `addon` submolder and store the code for your add-on. +1. In the folder where your add-on repository is cloned, create an `addon` subfolder and store the code for your add-on. 1. Go to the folder where your repository was cloned: From bb2a620553c85a83a142b77923dda9f6326cfd17 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 9 Jul 2026 21:25:46 +0200 Subject: [PATCH 06/32] Refactor update script logic and improve configuration options - Remove trailing "Press Enter to exit..." prompt at successful script completion to allow seamless execution in automated environments. - Implement specific file/folder exclusion handling during synchronization, with full architecture setup requested by @CyrilleB79. --- updateAddon.py | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/updateAddon.py b/updateAddon.py index 9c7a805..0af8b6d 100644 --- a/updateAddon.py +++ b/updateAddon.py @@ -353,6 +353,12 @@ def main() -> None: print("[*] Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + # List of template-relative paths to ignore during synchronization (requested by @CyrilleB79) + # Lowercase paths are used to prevent case mismatch issues across OS environments + IGNORED_FILES = { + os.path.join(".github", "workflows", "build_addon.yml").lower(), + } + with tempfile.TemporaryDirectory() as tempDir: print("[*] Cloning template into temporary workspace...") templateUrl = "https://github.com/nvaccess/AddonTemplate.git" @@ -386,6 +392,16 @@ def main() -> None: } syncReport = [] + # Custom ignore handler for shutil.copytree to filter subdirectories and files + def tree_ignore_handler(path: str, names: list[str]) -> list[str]: + ignored = [] + for name in names: + full_sub_path = os.path.join(path, name) + rel_sub_path = os.path.relpath(full_sub_path, start=tempDir).lower() + if rel_sub_path in IGNORED_FILES: + ignored.append(name) + return ignored + for item in os.listdir(tempDir): if item.lower() in protectedElements: syncReport.append(f"{item} .................... skipped (protected scope)") @@ -396,12 +412,18 @@ def main() -> None: srcItem = os.path.join(tempDir, item) dstItem = os.path.join(addonDir, item) + + # Check if the root item itself is explicitly ignored + relItemPath = os.path.relpath(srcItem, start=tempDir).lower() + if relItemPath in IGNORED_FILES: + syncReport.append(f"{item} .................... skipped (user ignored)") + continue try: if os.path.isdir(srcItem): if not args.dryRun: os.makedirs(dstItem, exist_ok=True) - shutil.copytree(srcItem, dstItem, dirs_exist_ok=True) + shutil.copytree(srcItem, dstItem, ignore=tree_ignore_handler, dirs_exist_ok=True) syncReport.append(f"{item}/ ................... merged safely") else: if not args.dryRun: @@ -435,8 +457,7 @@ def main() -> None: else: print("\n[+] Simulation finished. Workspace cleared.") - input("\nPress Enter to exit...") - if __name__ == "__main__": main() + From e849087c73f91cdf2c0af11cdb1af135c32ab5be Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:26:39 +0000 Subject: [PATCH 07/32] Pre-commit auto-fix --- updateAddon.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/updateAddon.py b/updateAddon.py index 0af8b6d..61ff69b 100644 --- a/updateAddon.py +++ b/updateAddon.py @@ -412,7 +412,7 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: srcItem = os.path.join(tempDir, item) dstItem = os.path.join(addonDir, item) - + # Check if the root item itself is explicitly ignored relItemPath = os.path.relpath(srcItem, start=tempDir).lower() if relItemPath in IGNORED_FILES: @@ -460,4 +460,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - From 436ba6f02235696879a738ffc604a009697b1819 Mon Sep 17 00:00:00 2001 From: abdel792 Date: Fri, 10 Jul 2026 08:34:50 +0200 Subject: [PATCH 08/32] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- updateAddon.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/updateAddon.py b/updateAddon.py index 61ff69b..1f2257a 100644 --- a/updateAddon.py +++ b/updateAddon.py @@ -179,10 +179,11 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = if "dependencies" in project_section: tpl_deps = project_section["dependencies"] - # Extract base package name (e.g. 'pyright' from 'pyright[nodejs]==1.1.407') + # Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.) def get_base(s: str) -> str: - return s.split("[")[0].split("=")[0].split(">")[0].strip().lower() - + import re + m = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", s.strip()) + return m.group(0).lower() if m else s.strip().lower() tpl_bases = {get_base(d) for d in tpl_deps} # Append custom user dependencies only if they are not already defined by the template From e385fca0092492a286baf293bb867298dbfb88c8 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 10 Jul 2026 17:46:58 +0200 Subject: [PATCH 09/32] chore: rename update script and align infrastructure with suggestions - Rename `updateAddon.py` to `updateAddonFromTemplate.py` for clarity. - Update `pyproject.toml` to exclude the renamed script from ruff and pyright. - Overhaul `updatingExistingAddons.md` documentation to match the new script name and integrate Copilot's review recommendations regarding prerequisites, correct paths, and accurate backup details. --- updateAddon.py => updateAddonFromTemplate.py | 46 ++++++++++++-------- 1 file changed, 29 insertions(+), 17 deletions(-) rename updateAddon.py => updateAddonFromTemplate.py (92%) diff --git a/updateAddon.py b/updateAddonFromTemplate.py similarity index 92% rename from updateAddon.py rename to updateAddonFromTemplate.py index 1f2257a..674d81a 100644 --- a/updateAddon.py +++ b/updateAddonFromTemplate.py @@ -21,20 +21,22 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: - """Recursively merges dictTpl into dictProj. Supports both dict and tomlkit container types. + """Recursively merges dictTpl into dictProj. - :param dictProj: The original dictionary to be updated. - :param dictTpl: The template dictionary whose values will be merged into dictProj. - :return: The updated dictProj with merged values from dictTpl. + Note: tomlkit returns custom table/array objects that behave like mappings/sequences but are not + instances of built-in `dict`/`list`, so we must detect by ABCs rather than concrete types. """ + from collections.abc import MutableMapping, MutableSequence + for key, value in dictTpl.items(): if key in dictProj: - if isinstance(dictProj[key], dict) and isinstance(value, dict): - deepMergeDicts(dictProj[key], value) - elif isinstance(dictProj[key], list) and isinstance(value, list): + projVal = dictProj[key] + if isinstance(projVal, MutableMapping) and isinstance(value, MutableMapping): + deepMergeDicts(projVal, value) + elif isinstance(projVal, MutableSequence) and isinstance(value, MutableSequence): for item in value: - if item not in dictProj[key]: - dictProj[key].append(item) + if item not in projVal: + projVal.append(item) else: pass else: @@ -242,8 +244,8 @@ def mergeBuildvarsFile( if val is None: formattedVal = "None" elif isinstance(val, str): - is_multiline = key in ["addon_summary", "addon_description", "addon_changelog"] - formattedVal = f'_("""{val}""")' if is_multiline else f'"{val}"' + is_translatable = key in ["addon_summary", "addon_description", "addon_changelog"] + formattedVal = f"_({val!r})" if is_translatable else repr(val) else: formattedVal = str(val) @@ -313,8 +315,14 @@ def main() -> None: ) args = parser.parse_args() - addonDirInput = args.addonDir if args.addonDir else os.getcwd() - addonDir = os.path.abspath(addonDirInput) + addonDirInput = args.addonDir + if addonDirInput: + addonDir = os.path.abspath(addonDirInput) + else: + # If executed from a subdirectory, walk upwards to find the add-on root. + cwd = Path(os.getcwd()).resolve() + addonRoot = next((p for p in (cwd, *cwd.parents) if (p / "buildVars.py").exists()), None) + addonDir = str(addonRoot) if addonRoot is not None else str(cwd) print("=== NVDA ADD-ON UPDATE TOOL ===") print(f"[*] Target Directory: {addonDir}") @@ -324,7 +332,8 @@ def main() -> None: if not os.path.exists(oldBuildvars): print(f"[-] Error: '{addonDir}' does not appear to be a valid NVDA Add-on (missing buildVars.py).") - input("\nPress Enter to exit...") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") sys.exit(1) print("[*] Phase 1: Analyzing existing project structure and metadata...") @@ -347,7 +356,8 @@ def main() -> None: print("[+] Backup created successfully.") except Exception as e: print(f"[-] Critical: Backup failed ({e}). Aborting update.") - input("\nPress Enter to exit...") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") sys.exit(1) else: print("[*] Phase 2: Safety backup skipped.") @@ -376,7 +386,8 @@ def main() -> None: print("[-] Error: Failed to execute git clone. Make sure Git is available in your PATH.") if isinstance(e, subprocess.CalledProcessError) and e.stderr: print(f"Details: {e.stderr.decode('utf-8', errors='ignore')}") - input("\nPress Enter to exit...") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") sys.exit(1) print("[*] Synchronizing template machinery files...") @@ -389,7 +400,7 @@ def main() -> None: ".venv", "docs", ".ruff_cache", - "updateaddon.py", + "updateaddonfromtemplate.py", } syncReport = [] @@ -461,3 +472,4 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() + \ No newline at end of file From 84fadc8f7c92bc9a5475baf96f0faa8d21f9ab76 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:47:28 +0000 Subject: [PATCH 10/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 674d81a..cd6401b 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -472,4 +472,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - \ No newline at end of file From e98421678ac884da18f77a682659fdf200519ee4 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 11 Jul 2026 23:29:55 +0200 Subject: [PATCH 11/32] feat(machinery): force multiline formatting for maintainers in generated pyproject.toml Updated `mergePyprojectToml` to configure `tomlkit` with `.multiline(True)` when programmatically migrating legacy `addon_author` metadata. This ensures that authors/maintainers are generated in a clean, vertically indented structure during initial add-on template migrations, matching official repository design guidelines. --- updateAddonFromTemplate.py | 46 +++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index cd6401b..83ec183 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -110,11 +110,12 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict return metadata, globalVars -def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = False) -> str: +def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict[str, Any], dryRun: bool = False) -> str: """Merge template pyproject.toml configuration into the developer's file. :param projPath: Path to the existing pyproject.toml file. :param tplPath: Path to the template pyproject.toml file. + :param metadata: Dictionary containing legacy metadata values from buildVars.py. :param dryRun: If True, simulate the merge without writing changes to disk. :return: A string indicating the result of the merge operation. """ @@ -125,9 +126,43 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, dryRun: bool = return "skipped (no template)" if not pProj.exists(): - if not dryRun: - shutil.copy2(pTpl, pProj) - return "created from template" + try: + with pTpl.open("r", encoding="utf-8") as f: + projData = tomlkit.parse(f.read()) + + if "project" in projData: + if "addon_name" in metadata and metadata["addon_name"]: + projData["project"]["name"] = metadata["addon_name"] + + if "addon_summary" in metadata and metadata["addon_summary"]: + projData["project"]["description"] = metadata["addon_summary"] + + if "addon_author" in metadata and metadata["addon_author"]: + import re + authors_list = tomlkit.array() + authors_list.multiline(True) + + parts = [p.strip() for p in metadata["addon_author"].split(",")] + for part in parts: + m = re.match(r"^(.*?)\s*<(.*?)>$", part) + if m: + t = tomlkit.inline_table() + t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) + authors_list.append(t) + elif part: + t = tomlkit.inline_table() + t.update({"name": part, "email": ""}) + authors_list.append(t) + + if len(authors_list) > 0: + projData["project"]["maintainers"] = authors_list + + if not dryRun: + with pProj.open("w", encoding="utf-8") as f: + f.write(tomlkit.dumps(projData)) + return "created from template" + except Exception as e: + return f"failed to create from template ({str(e)})" try: with pProj.open("r", encoding="utf-8") as f: @@ -449,7 +484,7 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: templatePyproject = os.path.join(tempDir, "pyproject.toml") bvStatus = mergeBuildvarsFile(oldBuildvars, templateBuildvars, bvMeta, bvGlobals, args.dryRun) - ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, args.dryRun) + ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, bvMeta, args.dryRun) print("\n" + "=" * 50) print("UPDATE REPORT") @@ -472,3 +507,4 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() + \ No newline at end of file From ca68c7f5f3aea9b93acf1dde8f502564d22d8a00 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:31:04 +0000 Subject: [PATCH 12/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 83ec183..3503753 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -129,19 +129,19 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict try: with pTpl.open("r", encoding="utf-8") as f: projData = tomlkit.parse(f.read()) - + if "project" in projData: if "addon_name" in metadata and metadata["addon_name"]: projData["project"]["name"] = metadata["addon_name"] - + if "addon_summary" in metadata and metadata["addon_summary"]: projData["project"]["description"] = metadata["addon_summary"] - + if "addon_author" in metadata and metadata["addon_author"]: import re authors_list = tomlkit.array() authors_list.multiline(True) - + parts = [p.strip() for p in metadata["addon_author"].split(",")] for part in parts: m = re.match(r"^(.*?)\s*<(.*?)>$", part) @@ -153,7 +153,7 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict t = tomlkit.inline_table() t.update({"name": part, "email": ""}) authors_list.append(t) - + if len(authors_list) > 0: projData["project"]["maintainers"] = authors_list @@ -507,4 +507,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - \ No newline at end of file From 26f6c5fa4deb4dc35f4ffb54a31858b0fa7a6323 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 12 Jul 2026 09:10:40 +0200 Subject: [PATCH 13/32] docs(machinery): comment out default build_addon.yml ignore example Commented out the default `build_addon.yml` path entry within the `IGNORED_FILES` set in `updateAddonFromTemplate.py`. This ensures all infrastructure components are synchronized out of the box while providing developers with a clear syntax example for excluding custom files or directories. --- updateAddonFromTemplate.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 3503753..321f863 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -129,19 +129,19 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict try: with pTpl.open("r", encoding="utf-8") as f: projData = tomlkit.parse(f.read()) - + if "project" in projData: if "addon_name" in metadata and metadata["addon_name"]: projData["project"]["name"] = metadata["addon_name"] - + if "addon_summary" in metadata and metadata["addon_summary"]: projData["project"]["description"] = metadata["addon_summary"] - + if "addon_author" in metadata and metadata["addon_author"]: import re authors_list = tomlkit.array() authors_list.multiline(True) - + parts = [p.strip() for p in metadata["addon_author"].split(",")] for part in parts: m = re.match(r"^(.*?)\s*<(.*?)>$", part) @@ -153,7 +153,7 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict t = tomlkit.inline_table() t.update({"name": part, "email": ""}) authors_list.append(t) - + if len(authors_list) > 0: projData["project"]["maintainers"] = authors_list @@ -402,7 +402,7 @@ def main() -> None: # List of template-relative paths to ignore during synchronization (requested by @CyrilleB79) # Lowercase paths are used to prevent case mismatch issues across OS environments IGNORED_FILES = { - os.path.join(".github", "workflows", "build_addon.yml").lower(), + #os.path.join(".github", "workflows", "build_addon.yml").lower(), } with tempfile.TemporaryDirectory() as tempDir: @@ -507,3 +507,4 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() + \ No newline at end of file From 738f5beb98ec794b867c0616a4cedb2539711997 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:11:00 +0000 Subject: [PATCH 14/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 321f863..b5db313 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -129,19 +129,19 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict try: with pTpl.open("r", encoding="utf-8") as f: projData = tomlkit.parse(f.read()) - + if "project" in projData: if "addon_name" in metadata and metadata["addon_name"]: projData["project"]["name"] = metadata["addon_name"] - + if "addon_summary" in metadata and metadata["addon_summary"]: projData["project"]["description"] = metadata["addon_summary"] - + if "addon_author" in metadata and metadata["addon_author"]: import re authors_list = tomlkit.array() authors_list.multiline(True) - + parts = [p.strip() for p in metadata["addon_author"].split(",")] for part in parts: m = re.match(r"^(.*?)\s*<(.*?)>$", part) @@ -153,7 +153,7 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict t = tomlkit.inline_table() t.update({"name": part, "email": ""}) authors_list.append(t) - + if len(authors_list) > 0: projData["project"]["maintainers"] = authors_list @@ -507,4 +507,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - \ No newline at end of file From f745058c3da3fdef917d0a288f0f1113ad936e5c Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 12 Jul 2026 10:55:08 +0200 Subject: [PATCH 15/32] fix(machinery): enforce tab indentation for generated pyproject.toml Updated the `mergePyprojectToml` function in `updateAddonFromTemplate.py` to ensure that when a new `pyproject.toml` is created from the template for legacy add-ons, any generated multiline arrays (such as `maintainers`) strictly use tab indentation instead of four spaces. --- updateAddonFromTemplate.py | 65 ++++++++++++++++++++++---------------- 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index b5db313..3483ce5 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -129,37 +129,45 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict try: with pTpl.open("r", encoding="utf-8") as f: projData = tomlkit.parse(f.read()) - - if "project" in projData: - if "addon_name" in metadata and metadata["addon_name"]: - projData["project"]["name"] = metadata["addon_name"] - - if "addon_summary" in metadata and metadata["addon_summary"]: - projData["project"]["description"] = metadata["addon_summary"] - - if "addon_author" in metadata and metadata["addon_author"]: - import re - authors_list = tomlkit.array() - authors_list.multiline(True) - - parts = [p.strip() for p in metadata["addon_author"].split(",")] - for part in parts: - m = re.match(r"^(.*?)\s*<(.*?)>$", part) - if m: - t = tomlkit.inline_table() - t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) - authors_list.append(t) - elif part: - t = tomlkit.inline_table() - t.update({"name": part, "email": ""}) - authors_list.append(t) - - if len(authors_list) > 0: - projData["project"]["maintainers"] = authors_list + + if "project" not in projData: + projData["project"] = tomlkit.table() + + if "addon_name" in metadata and metadata["addon_name"]: + projData["project"]["name"] = metadata["addon_name"] + + if "addon_summary" in metadata and metadata["addon_summary"]: + projData["project"]["description"] = metadata["addon_summary"] + + # Build the maintainers multiline array using standard inline tables + authors_list = tomlkit.array() + authors_list.multiline(True) + + if "addon_author" in metadata and metadata["addon_author"]: + import re + raw_authors = str(metadata["addon_author"]) + parts = [p.strip() for p in raw_authors.split(",") if p.strip()] + + for part in parts: + m = re.match(r"^(.*?)\s*<(.*?)>$", part) + if m: + t = tomlkit.inline_table() + t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) + authors_list.append(t) + elif part: + t = tomlkit.inline_table() + t.update({"name": part, "email": ""}) + authors_list.append(t) + + projData["project"]["maintainers"] = authors_list if not dryRun: + # Dump to string and sanitize indentation to strict tabs before writing + toml_output = tomlkit.dumps(projData) + toml_output = toml_output.replace(" ", "\t") + with pProj.open("w", encoding="utf-8") as f: - f.write(tomlkit.dumps(projData)) + f.write(toml_output) return "created from template" except Exception as e: return f"failed to create from template ({str(e)})" @@ -507,3 +515,4 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() + \ No newline at end of file From e9f6522224dfad3fe8ab6bfc744bccefb0c4dc46 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:56:07 +0000 Subject: [PATCH 16/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 3483ce5..548a5a9 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -129,25 +129,25 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict try: with pTpl.open("r", encoding="utf-8") as f: projData = tomlkit.parse(f.read()) - + if "project" not in projData: projData["project"] = tomlkit.table() - + if "addon_name" in metadata and metadata["addon_name"]: projData["project"]["name"] = metadata["addon_name"] - + if "addon_summary" in metadata and metadata["addon_summary"]: projData["project"]["description"] = metadata["addon_summary"] - + # Build the maintainers multiline array using standard inline tables authors_list = tomlkit.array() authors_list.multiline(True) - + if "addon_author" in metadata and metadata["addon_author"]: import re raw_authors = str(metadata["addon_author"]) parts = [p.strip() for p in raw_authors.split(",") if p.strip()] - + for part in parts: m = re.match(r"^(.*?)\s*<(.*?)>$", part) if m: @@ -158,14 +158,14 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict t = tomlkit.inline_table() t.update({"name": part, "email": ""}) authors_list.append(t) - + projData["project"]["maintainers"] = authors_list if not dryRun: # Dump to string and sanitize indentation to strict tabs before writing toml_output = tomlkit.dumps(projData) toml_output = toml_output.replace(" ", "\t") - + with pProj.open("w", encoding="utf-8") as f: f.write(toml_output) return "created from template" @@ -515,4 +515,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - \ No newline at end of file From ba675afbcbc3f59346a411f00b095bae187ab63d Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 00:07:08 +0200 Subject: [PATCH 17/32] docs: restore module docstrings contributed by @nvdaes Restores the comprehensive function docstrings and parameter descriptions originally written by @nvdaes, which were accidentally removed during the recent module refactoring. All documentation is aligned with the current module structure. --- updateAddonFromTemplate.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 548a5a9..4250d07 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -25,6 +25,10 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st Note: tomlkit returns custom table/array objects that behave like mappings/sequences but are not instances of built-in `dict`/`list`, so we must detect by ABCs rather than concrete types. + + :param dictProj: The original dictionary to be updated. + :param dictTpl: The template dictionary whose values will be merged into dictProj. + :return: The updated dictProj with merged values from dictTpl. """ from collections.abc import MutableMapping, MutableSequence @@ -254,12 +258,12 @@ def mergeBuildvarsFile( ) -> str: """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks. - :param projPath: Path to the existing buildVars.py file. - :param tplPath: Path to the template buildVars.py file. - :param metadata: Dictionary containing metadata values to update. - :param globalVars: Dictionary containing global variable values to update. - :param dryRun: If True, simulate the merge without writing changes to disk. - :Return: A string indicating the result of the merge operation. + :param projPath: Path to the existing buildVars.py file. + :param tplPath: Path to the template buildVars.py file. + :param metadata: Dictionary containing metadata values to update. + :param globalVars: Dictionary containing global variable values to update. + :param dryRun: If True, simulate the merge without writing changes to disk. + :return: A string indicating the result of the merge operation. """ pTpl = Path(tplPath) pProj = Path(projPath) @@ -515,3 +519,4 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() + \ No newline at end of file From 90a8a6d97f7637765fe1f09e287824fbae22cb47 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:08:03 +0000 Subject: [PATCH 18/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 4250d07..82b8d47 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -519,4 +519,3 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: if __name__ == "__main__": main() - \ No newline at end of file From 58660aa26e5de40339e86c4b0426546b99bb305f Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 14:13:40 +0200 Subject: [PATCH 19/32] Refactor update engine and unify CLI flags with short/long options - Extract core update logic from main() into a standalone runSynchronization() function. - Convert the positional add-on directory argument into a standard named option with short (-ad) and long (--addon-dir) flags. - Add -td and --template-dir short/long flags to support using a local NVDA AddonTemplate folder. - Streamline Phase 3 logic to cleanly bypass remote Git cloning when a local template directory is supplied. - Fix a duplicate parser declaration line for the --dry-run option. --- updateAddonFromTemplate.py | 245 +++++++++++++++++++++---------------- 1 file changed, 137 insertions(+), 108 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 82b8d47..8a433fe 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -292,7 +292,7 @@ def mergeBuildvarsFile( formattedVal = "None" elif isinstance(val, str): is_translatable = key in ["addon_summary", "addon_description", "addon_changelog"] - formattedVal = f"_({val!r})" if is_translatable else repr(val) + formattedVal = f'_({val!r})' if is_translatable else repr(val) else: formattedVal = str(val) @@ -337,25 +337,123 @@ def mergeBuildvarsFile( return "merged & structured (AST verified)" +def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: + """Synchronizes template machinery files from the temporary workspace into the target directory. + + :param tempDir: Path to the local temporary directory containing the template files. + :param addonDir: Path to the target add-on root directory. + :param dryRun: If True, simulate the sync without writing changes to disk. + """ + print("[*] Synchronizing template machinery files...") + protectedElements = { + "readme.md", + "changelog.md", + "addon", + ".git", + "__pycache__", + ".venv", + "docs", + ".ruff_cache", + #"updateaddonfromtemplate.py", + } + syncReport = [] + + # List of template-relative paths to ignore during synchronization (requested by @CyrilleB79) + IGNORED_FILES = set() + + # Custom ignore handler for shutil.copytree to filter subdirectories and files + def tree_ignore_handler(path: str, names: list[str]) -> list[str]: + ignored = [] + for name in names: + full_sub_path = os.path.join(path, name) + rel_sub_path = os.path.relpath(full_sub_path, start=tempDir).lower() + if rel_sub_path in IGNORED_FILES: + ignored.append(name) + return ignored + + for item in os.listdir(tempDir): + if item.lower() in protectedElements: + syncReport.append(f"{item} .................... skipped (protected scope)") + continue + + if item in ["buildVars.py", "pyproject.toml"]: + continue + + srcItem = os.path.join(tempDir, item) + dstItem = os.path.join(addonDir, item) + + # Check if the root item itself is explicitly ignored + relItemPath = os.path.relpath(srcItem, start=tempDir).lower() + if relItemPath in IGNORED_FILES: + syncReport.append(f"{item} .................... skipped (user ignored)") + continue + + try: + if os.path.isdir(srcItem): + if not dryRun: + os.makedirs(dstItem, exist_ok=True) + shutil.copytree(srcItem, dstItem, ignore=tree_ignore_handler, dirs_exist_ok=True) + syncReport.append(f"{item}/ ................... merged safely") + else: + if not dryRun: + shutil.copy2(srcItem, dstItem) + syncReport.append(f"{item} .................... synchronized") + except Exception as e: + syncReport.append(f"{item} .................... failed ({str(e)})") + + print("[*] Phase 4: Processing structural configuration merges...") + templateBuildvars = os.path.join(tempDir, "buildVars.py") + templatePyproject = os.path.join(tempDir, "pyproject.toml") + + oldBuildvars = os.path.join(addonDir, "buildVars.py") + oldPyproject = os.path.join(addonDir, "pyproject.toml") + + bvMeta, bvGlobals = extractBuildvarsMetadata(oldBuildvars) + addonName = bvMeta.get("addon_name", os.path.basename(addonDir)) + + bvStatus = mergeBuildvarsFile(oldBuildvars, templateBuildvars, bvMeta, bvGlobals, dryRun) + ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, bvMeta, dryRun) + + print("\n" + "=" * 50) + print("UPDATE REPORT") + print("=" * 50) + print(f"Add-on ....................... {addonName}") + print("\nTemplate synchronization:") + for entry in syncReport: + print(f" - {entry}") + print( + f"\nConfiguration files:\n" + f" buildVars.py ............... {bvStatus}\n" + f" pyproject.toml ............. {ppStatus}", + ) + + def main() -> None: """Execute main CLI entry point for the NVDA Add-on update tool.""" parser = argparse.ArgumentParser( description="Non-destructive industrial update tool for NVDA Add-ons.", ) parser.add_argument( - "addonDir", - nargs="?", + "-ad", "--addon-dir", + dest="addonDir", default=None, help="Path to the root directory of the add-on to update (defaults to current directory).", ) parser.add_argument( - "--dry-run", + "-td", "--template-dir", + dest="templateDir", + default=None, + help="Path to a local directory containing the NVDA AddonTemplate to use instead of fetching it via Git.", + ) + parser.add_argument( + "-dr", "--dry-run", + "-dr", "--dry-run", dest="dryRun", action="store_true", help="Simulate execution without modifying any files.", ) parser.add_argument( - "--skip-backup", + "-sk", "--skip-backup", dest="skipBackup", action="store_true", help="Disable safety automatic project backup.", @@ -375,7 +473,6 @@ def main() -> None: print(f"[*] Target Directory: {addonDir}") oldBuildvars = os.path.join(addonDir, "buildVars.py") - oldPyproject = os.path.join(addonDir, "pyproject.toml") if not os.path.exists(oldBuildvars): print(f"[-] Error: '{addonDir}' does not appear to be a valid NVDA Add-on (missing buildVars.py).") @@ -384,7 +481,7 @@ def main() -> None: sys.exit(1) print("[*] Phase 1: Analyzing existing project structure and metadata...") - bvMeta, bvGlobals = extractBuildvarsMetadata(oldBuildvars) + bvMeta, _ = extractBuildvarsMetadata(oldBuildvars) addonName = bvMeta.get("addon_name", os.path.basename(addonDir)) print(f"[+] Target Add-on Identified: {addonName}") @@ -409,113 +506,45 @@ def main() -> None: else: print("[*] Phase 2: Safety backup skipped.") - print("[*] Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") - - # List of template-relative paths to ignore during synchronization (requested by @CyrilleB79) - # Lowercase paths are used to prevent case mismatch issues across OS environments - IGNORED_FILES = { - #os.path.join(".github", "workflows", "build_addon.yml").lower(), - } - - with tempfile.TemporaryDirectory() as tempDir: - print("[*] Cloning template into temporary workspace...") - templateUrl = "https://github.com/nvaccess/AddonTemplate.git" - - try: - subprocess.run( - ["git", "clone", "--depth", "1", templateUrl, tempDir], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - print("[+] Template cloned successfully.") - except (subprocess.CalledProcessError, FileNotFoundError) as e: - print("[-] Error: Failed to execute git clone. Make sure Git is available in your PATH.") - if isinstance(e, subprocess.CalledProcessError) and e.stderr: - print(f"Details: {e.stderr.decode('utf-8', errors='ignore')}") + if args.templateDir: + templatePath = os.path.abspath(args.templateDir) + print(f"[*] Phase 3: Using local template directory: {templatePath}") + if not os.path.exists(os.path.join(templatePath, "buildVars.py")): + print("[-] Error: Provided template directory does not appear to be a valid NVDA AddonTemplate (missing buildVars.py).") if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) - - print("[*] Synchronizing template machinery files...") - protectedElements = { - "readme.md", - "changelog.md", - "addon", - ".git", - "__pycache__", - ".venv", - "docs", - ".ruff_cache", - "updateaddonfromtemplate.py", - } - syncReport = [] - - # Custom ignore handler for shutil.copytree to filter subdirectories and files - def tree_ignore_handler(path: str, names: list[str]) -> list[str]: - ignored = [] - for name in names: - full_sub_path = os.path.join(path, name) - rel_sub_path = os.path.relpath(full_sub_path, start=tempDir).lower() - if rel_sub_path in IGNORED_FILES: - ignored.append(name) - return ignored - - for item in os.listdir(tempDir): - if item.lower() in protectedElements: - syncReport.append(f"{item} .................... skipped (protected scope)") - continue - - if item in ["buildVars.py", "pyproject.toml"]: - continue - - srcItem = os.path.join(tempDir, item) - dstItem = os.path.join(addonDir, item) - - # Check if the root item itself is explicitly ignored - relItemPath = os.path.relpath(srcItem, start=tempDir).lower() - if relItemPath in IGNORED_FILES: - syncReport.append(f"{item} .................... skipped (user ignored)") - continue + runSynchronization(templatePath, addonDir, args.dryRun) + else: + print("[*] Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + with tempfile.TemporaryDirectory() as tempDir: + print("[*] Cloning template into temporary workspace...") + templateUrl = "https://github.com/nvaccess/AddonTemplate.git" try: - if os.path.isdir(srcItem): - if not args.dryRun: - os.makedirs(dstItem, exist_ok=True) - shutil.copytree(srcItem, dstItem, ignore=tree_ignore_handler, dirs_exist_ok=True) - syncReport.append(f"{item}/ ................... merged safely") - else: - if not args.dryRun: - shutil.copy2(srcItem, dstItem) - syncReport.append(f"{item} .................... synchronized") - except Exception as e: - syncReport.append(f"{item} .................... failed ({str(e)})") - - print("[*] Phase 4: Processing structural configuration merges...") - templateBuildvars = os.path.join(tempDir, "buildVars.py") - templatePyproject = os.path.join(tempDir, "pyproject.toml") - - bvStatus = mergeBuildvarsFile(oldBuildvars, templateBuildvars, bvMeta, bvGlobals, args.dryRun) - ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, bvMeta, args.dryRun) - - print("\n" + "=" * 50) - print("UPDATE REPORT") - print("=" * 50) - print(f"Add-on ....................... {addonName}") - print("\nTemplate synchronization:") - for entry in syncReport: - print(f" - {entry}") - print( - f"\nConfiguration files:\n" - f" buildVars.py ............... {bvStatus}\n" - f" pyproject.toml ............. {ppStatus}", - ) - - if not args.dryRun: - print("\n[+] Project successfully updated. Temporary workspace destroyed.") - else: - print("\n[+] Simulation finished. Workspace cleared.") + subprocess.run( + ["git", "clone", "--depth", "1", templateUrl, tempDir], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + print("[+] Template cloned successfully.") + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print("[-] Error: Failed to execute git clone. Make sure Git is available in your PATH.") + if isinstance(e, subprocess.CalledProcessError) and e.stderr: + print(f"Details: {e.stderr.decode('utf-8', errors='ignore')}") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) + + runSynchronization(tempDir, addonDir, args.dryRun) + + if not args.dryRun: + print("\n[+] Project successfully updated. Workspace cleared.") + else: + print("\n[+] Simulation finished. Workspace cleared.") if __name__ == "__main__": main() + \ No newline at end of file From a5d429fa4f5694501185a43850d1075975bff8f8 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:14:23 +0000 Subject: [PATCH 20/32] Pre-commit auto-fix --- updateAddonFromTemplate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 8a433fe..c1ff085 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -547,4 +547,3 @@ def main() -> None: if __name__ == "__main__": main() - \ No newline at end of file From 682f652325c8d1091ed675f8babd01c50d2b56fe Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 22:01:00 +0200 Subject: [PATCH 21/32] refactor: remove obsolete IGNORED_FILES set and clean up synchronization logic --- updateAddonFromTemplate.py | 21 +-------------------- 1 file changed, 1 insertion(+), 20 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index c1ff085..961805f 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -358,19 +358,6 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: } syncReport = [] - # List of template-relative paths to ignore during synchronization (requested by @CyrilleB79) - IGNORED_FILES = set() - - # Custom ignore handler for shutil.copytree to filter subdirectories and files - def tree_ignore_handler(path: str, names: list[str]) -> list[str]: - ignored = [] - for name in names: - full_sub_path = os.path.join(path, name) - rel_sub_path = os.path.relpath(full_sub_path, start=tempDir).lower() - if rel_sub_path in IGNORED_FILES: - ignored.append(name) - return ignored - for item in os.listdir(tempDir): if item.lower() in protectedElements: syncReport.append(f"{item} .................... skipped (protected scope)") @@ -382,17 +369,11 @@ def tree_ignore_handler(path: str, names: list[str]) -> list[str]: srcItem = os.path.join(tempDir, item) dstItem = os.path.join(addonDir, item) - # Check if the root item itself is explicitly ignored - relItemPath = os.path.relpath(srcItem, start=tempDir).lower() - if relItemPath in IGNORED_FILES: - syncReport.append(f"{item} .................... skipped (user ignored)") - continue - try: if os.path.isdir(srcItem): if not dryRun: os.makedirs(dstItem, exist_ok=True) - shutil.copytree(srcItem, dstItem, ignore=tree_ignore_handler, dirs_exist_ok=True) + shutil.copytree(srcItem, dstItem, dirs_exist_ok=True) syncReport.append(f"{item}/ ................... merged safely") else: if not dryRun: From 2d2412fbf9f8bbb7b3a7b9515437ec93d32131b3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 06:39:58 +0200 Subject: [PATCH 22/32] feat(sync): add .addonmergeignore support and refactor code to strict camelCase - Add support for custom exclusion patterns via a local `.addonmergeignore` file in the target add-on root. - Refactor all functions, variables, and CLI arguments to strictly adhere to camelCase naming conventions. - Standardise all docstrings to use the Sphinx documentation format (including detailed `:param:` and `:return:` fields). - Modernise and tighten type hinting across the entire module (using Python 3.10+ native unions `|` and explicit annotations). --- updateAddonFromTemplate.py | 113 ++++++++++++++++++++++--------------- 1 file changed, 68 insertions(+), 45 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 961805f..58f5283 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -67,7 +67,7 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict metadata: dict[str, Any] = {} globalVars: dict[str, Any] = {} - topLevelVars = { + topLevelVars: set[str] = { "pythonSources", "excludedFiles", "baseLanguage", @@ -144,34 +144,34 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict projData["project"]["description"] = metadata["addon_summary"] # Build the maintainers multiline array using standard inline tables - authors_list = tomlkit.array() - authors_list.multiline(True) + authorsList = tomlkit.array() + authorsList.multiline(True) if "addon_author" in metadata and metadata["addon_author"]: import re - raw_authors = str(metadata["addon_author"]) - parts = [p.strip() for p in raw_authors.split(",") if p.strip()] + rawAuthors = str(metadata["addon_author"]) + parts = [p.strip() for p in rawAuthors.split(",") if p.strip()] for part in parts: m = re.match(r"^(.*?)\s*<(.*?)>$", part) if m: t = tomlkit.inline_table() t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) - authors_list.append(t) + authorsList.append(t) elif part: t = tomlkit.inline_table() t.update({"name": part, "email": ""}) - authors_list.append(t) + authorsList.append(t) - projData["project"]["maintainers"] = authors_list + projData["project"]["maintainers"] = authorsList if not dryRun: # Dump to string and sanitize indentation to strict tabs before writing - toml_output = tomlkit.dumps(projData) - toml_output = toml_output.replace(" ", "\t") + tomlOutput = tomlkit.dumps(projData) + tomlOutput = tomlOutput.replace(" ", "\t") with pProj.open("w", encoding="utf-8") as f: - f.write(toml_output) + f.write(tomlOutput) return "created from template" except Exception as e: return f"failed to create from template ({str(e)})" @@ -183,7 +183,7 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict tplData = tomlkit.parse(f.read()) # Check if NV Access was ALREADY the original author/maintainer of the project - was_originally_nvaccess = False + wasOriginallyNvaccess = False if "project" in projData: for field in ["authors", "maintainers"]: if field in projData["project"] and isinstance(projData["project"][field], list): @@ -192,13 +192,13 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict if not name and isinstance(item, dict): name = item.get("name", "") if str(name).strip().lower() in ["nv access", "nvaccess"]: - was_originally_nvaccess = True + wasOriginallyNvaccess = True break # Backup dependencies from project to merge them manually later - proj_deps = [] + projDeps = [] if "project" in projData and "dependencies" in projData["project"]: - proj_deps = list(projData["project"]["dependencies"]) + projDeps = list(projData["project"]["dependencies"]) # Temporarily remove dependencies from project to let template comments win del projData["project"]["dependencies"] @@ -206,40 +206,45 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict mergedData = deepMergeDicts(cast(dict[str, Any], projData), cast(dict[str, Any], tplData)) if "project" in mergedData: - project_section = mergedData["project"] + projectSection = mergedData["project"] # 1. Conditional cleanup of NV Access placeholders # Only remove them if NV Access wasn't the original author of the add-on - if not was_originally_nvaccess: + if not wasOriginallyNvaccess: for field in ["authors", "maintainers"]: - if field in project_section and isinstance(project_section[field], list): - toml_list = project_section[field] + if field in projectSection and isinstance(projectSection[field], list): + tomlList = projectSection[field] # Reverse loop to safely delete by index within tomlkit structure - for i in range(len(toml_list) - 1, -1, -1): - item = toml_list[i] + for i in range(len(tomlList) - 1, -1, -1): + item = tomlList[i] name = item.get("name", "") if hasattr(item, "get") else "" if not name and isinstance(item, dict): name = item.get("name", "") if str(name).strip().lower() in ["nv access", "nvaccess"]: - toml_list.pop(i) + tomlList.pop(i) # 2. Smart merge of dependencies (Template layout and comments win) - if "dependencies" in project_section: - tpl_deps = project_section["dependencies"] + if "dependencies" in projectSection: + tplDeps = projectSection["dependencies"] # Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.) - def get_base(s: str) -> str: + def getBase(s: str) -> str: + """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). + + :param s: The raw dependency string. + :return: The normalized base package name in lowercase. + """ import re m = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", s.strip()) return m.group(0).lower() if m else s.strip().lower() - tpl_bases = {get_base(d) for d in tpl_deps} + tplBases = {getBase(d) for d in tplDeps} # Append custom user dependencies only if they are not already defined by the template - for dep in proj_deps: - base = get_base(dep) - if base not in tpl_bases: - tpl_deps.append(dep) + for dep in projDeps: + base = getBase(dep) + if base not in tplBases: + tplDeps.append(dep) if not dryRun: with pProj.open("w", encoding="utf-8") as f: @@ -291,14 +296,14 @@ def mergeBuildvarsFile( if val is None: formattedVal = "None" elif isinstance(val, str): - is_translatable = key in ["addon_summary", "addon_description", "addon_changelog"] - formattedVal = f'_({val!r})' if is_translatable else repr(val) + isTranslatable = key in ["addon_summary", "addon_description", "addon_changelog"] + formattedVal = f'_({val!r})' if isTranslatable else repr(val) else: formattedVal = str(val) if kw.end_lineno is not None: - line_content = tplLines[kw.lineno - 1] - indent = line_content[: len(line_content) - len(line_content.lstrip())] + lineContent = tplLines[kw.lineno - 1] + indent = lineContent[: len(lineContent) - len(lineContent.lstrip())] replacements[(kw.lineno - 1, kw.end_lineno)] = f"{indent}{key}={formattedVal},\n" elif isinstance(node, ast.Assign) and len(node.targets) == 1: @@ -307,8 +312,8 @@ def mergeBuildvarsFile( key = target.id valExpression = globalVars[key] if node.end_lineno is not None: - line_content = tplLines[node.lineno - 1] - indent = line_content[: len(line_content) - len(line_content.lstrip())] + lineContent = tplLines[node.lineno - 1] + indent = lineContent[: len(lineContent) - len(lineContent.lstrip())] prefix = f"{indent}import os\n" if "os." in valExpression else "" replacements[(node.lineno - 1, node.end_lineno)] = ( f"{prefix}{indent}{key} = {valExpression}\n" @@ -319,8 +324,8 @@ def mergeBuildvarsFile( key = node.target.id valExpression = globalVars[key] if node.end_lineno is not None: - line_content = tplLines[node.lineno - 1] - indent = line_content[: len(line_content) - len(line_content.lstrip())] + lineContent = tplLines[node.lineno - 1] + indent = lineContent[: len(lineContent) - len(lineContent.lstrip())] typeStr = ast.unparse(node.annotation) prefix = f"{indent}import os\n" if "os." in valExpression else "" replacements[(node.lineno - 1, node.end_lineno)] = ( @@ -343,6 +348,7 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: :param tempDir: Path to the local temporary directory containing the template files. :param addonDir: Path to the target add-on root directory. :param dryRun: If True, simulate the sync without writing changes to disk. + :return: None """ print("[*] Synchronizing template machinery files...") protectedElements = { @@ -354,9 +360,23 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: ".venv", "docs", ".ruff_cache", - #"updateaddonfromtemplate.py", } - syncReport = [] + + # Dynamically load custom ignore patterns from the target add-on directory if they exist + ignoreFilePath = os.path.join(addonDir, ".addonmergeignore") + if os.path.exists(ignoreFilePath): + print("[*] Reading local custom exclusions from .addonmergeignore...") + try: + with open(ignoreFilePath, "r", encoding="utf-8") as f: + for line in f: + cleanLine = line.strip().lower() + # Exclude comments and empty lines + if cleanLine and not cleanLine.startswith("#"): + protectedElements.add(cleanLine) + except Exception as e: + print(f"[-] Warning: Failed to parse .addonmergeignore ({e})") + + syncReport: list[str] = [] for item in os.listdir(tempDir): if item.lower() in protectedElements: @@ -427,7 +447,6 @@ def main() -> None: help="Path to a local directory containing the NVDA AddonTemplate to use instead of fetching it via Git.", ) parser.add_argument( - "-dr", "--dry-run", "-dr", "--dry-run", dest="dryRun", action="store_true", @@ -469,9 +488,14 @@ def main() -> None: if args.dryRun: print("[!] RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") - if not args.skipBackup and not args.dryRun: + print("[*] Phase 2: Safety backup verification...") + if args.dryRun: + print("[*] Safety backup skipped (simulation mode active).") + elif args.skipBackup: + print("[!] Safety backup skipped (--skip-backup requested by user).") + else: backupDir = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - print(f"[*] Phase 2: Creating safety automatic backup in: {os.path.basename(backupDir)}...") + print(f"[*] Creating safety automatic backup in: {os.path.basename(backupDir)}...") try: shutil.copytree( addonDir, @@ -484,8 +508,6 @@ def main() -> None: if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) - else: - print("[*] Phase 2: Safety backup skipped.") if args.templateDir: templatePath = os.path.abspath(args.templateDir) @@ -528,3 +550,4 @@ def main() -> None: if __name__ == "__main__": main() + \ No newline at end of file From ee954a4e1f59dc22ea238a8c07ef8b0f00fe48a1 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 07:00:35 +0200 Subject: [PATCH 23/32] feat(sync): add empty .addonmergeignore template to repository root Create an empty `.addonmergeignore` file at the root of the repository. This template file allows developers to easily specify local file and directory exclusion patterns to prevent them from being overwritten during subsequent synchronization phases. --- .addonmergeignore | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 .addonmergeignore diff --git a/.addonmergeignore b/.addonmergeignore new file mode 100644 index 0000000..e69de29 From e27d00bc3a12ffd9998d2a05fb53eb9687228861 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 11:41:25 +0200 Subject: [PATCH 24/32] docs: update updateAddonFromTemplate.py docs and refactor imports - Move all import statements to the module header for better code structure. - Update the documentation to explain the .addonmergeignore procedure instead of the manual IGNORED_FILES code modification. - Document the automated update tool CLI flags and options. --- updateAddonFromTemplate.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 58f5283..99605a5 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -2,6 +2,8 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import MutableMapping, MutableSequence +import re import argparse import ast import os @@ -30,8 +32,6 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st :param dictTpl: The template dictionary whose values will be merged into dictProj. :return: The updated dictProj with merged values from dictTpl. """ - from collections.abc import MutableMapping, MutableSequence - for key, value in dictTpl.items(): if key in dictProj: projVal = dictProj[key] @@ -148,7 +148,6 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict authorsList.multiline(True) if "addon_author" in metadata and metadata["addon_author"]: - import re rawAuthors = str(metadata["addon_author"]) parts = [p.strip() for p in rawAuthors.split(",") if p.strip()] @@ -235,7 +234,6 @@ def getBase(s: str) -> str: :param s: The raw dependency string. :return: The normalized base package name in lowercase. """ - import re m = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", s.strip()) return m.group(0).lower() if m else s.strip().lower() tplBases = {getBase(d) for d in tplDeps} From 7e89f5bbda6fe7c9f761b5b9a5a841b6ed60ec87 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 23:27:43 +0200 Subject: [PATCH 25/32] feat: support repository URL extraction during pyproject.toml creation - Extract the "addon_url" metadata from buildVars.py when generating a new pyproject.toml. - Write the "Repository" key to [project.urls], preserving empty values ("") if the URL is not set in the add-on. --- updateAddonFromTemplate.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/updateAddonFromTemplate.py b/updateAddonFromTemplate.py index 99605a5..a8dbfd8 100644 --- a/updateAddonFromTemplate.py +++ b/updateAddonFromTemplate.py @@ -143,6 +143,15 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict if "addon_summary" in metadata and metadata["addon_summary"]: projData["project"]["description"] = metadata["addon_summary"] + # Extract the repository URL from buildVars metadata + # Always preserve the key even if it is empty ("") + addonUrl = str(metadata.get("addon_url", "")).strip() + + if "urls" not in projData["project"]: + projData["project"]["urls"] = tomlkit.table() + + projData["project"]["urls"]["Repository"] = addonUrl + # Build the maintainers multiline array using standard inline tables authorsList = tomlkit.array() authorsList.multiline(True) From 0c84b888411f6579b1720d59ff116dc5fefaeeb9 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 15 Jul 2026 07:32:18 +0200 Subject: [PATCH 26/32] refactor: rename companion script to syncAddonWithTemplate.py - Rename the script from `updateAddonFromTemplate.py` to `syncAddonWithTemplate.py` to better reflect its synchronization purpose. - Update ruff and pyright exclusions and tool settings in `pyproject.toml` to reference the new file name. - Update the documentation (`updatingExistingAddons.md`) to align all text, examples, and `.addonmergeignore` instructions with the new module name. - Standardize all documentation commands to use the safer `uv run python` syntax. --- updateAddonFromTemplate.py => syncAddonWithTemplate.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename updateAddonFromTemplate.py => syncAddonWithTemplate.py (100%) diff --git a/updateAddonFromTemplate.py b/syncAddonWithTemplate.py similarity index 100% rename from updateAddonFromTemplate.py rename to syncAddonWithTemplate.py From aa134979535f31eb93cac1761dae303bf371f460 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 15 Jul 2026 09:51:34 +0200 Subject: [PATCH 27/32] refactor: make addonDir a positional optional argument - Change `addonDir` from an optional flagged argument (`-ad`/`--addon-dir`) to a positional optional argument using `nargs="?"`. - Allow running the script with a target directory directly (e.g., `syncAddonWithTemplate.py myAddon`) without needing the `-ad` flag. - Preserve the default behavior (`None`/current directory) when no path argument is provided. --- syncAddonWithTemplate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index a8dbfd8..e003e11 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -442,8 +442,8 @@ def main() -> None: description="Non-destructive industrial update tool for NVDA Add-ons.", ) parser.add_argument( - "-ad", "--addon-dir", - dest="addonDir", + "addonDir", + nargs="?", default=None, help="Path to the root directory of the add-on to update (defaults to current directory).", ) From a14d7b7083b12434c7013c05bdfa255518832fb3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 16 Jul 2026 08:24:41 +0200 Subject: [PATCH 28/32] docs: update synchronization documentation and test suite guidelines - Add instructions for running unit tests via pytest and uv. - Update syncAddonWithTemplate.py usage guides to enforce the mandatory -ad option. - Include guidelines for using uv run with the --with tomlkit flag to execute the companion tool without prior installation. - Align script usage and testing recommendations with guidelines from @seanbudd. --- syncAddonWithTemplate.py | 177 +++++++++++++++++++++++++-------------- 1 file changed, 112 insertions(+), 65 deletions(-) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index e003e11..124958b 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -22,6 +22,108 @@ TOMLKIT_AVAILABLE: bool = True +def parseAstDict(node: ast.Dict) -> dict[str, Any]: + """Extract key-value pairs from an AST Dict node. + + :param node: The ast.Dict node to parse. + :return: A dictionary containing the extracted keys and values. + """ + extracted: dict[str, Any] = {} + for keyNode, valNode in zip(node.keys, node.values): + if keyNode is None: + continue + key = getattr(keyNode, "value", None) + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val = getattr(valNode, "value", None) + if key is not None: + extracted[key] = val + return extracted + + +def parseAstKeywords(keywords: list[ast.keyword]) -> dict[str, Any]: + """Extract key-value pairs from a list of AST keyword nodes. + + :param keywords: The list of ast.keyword nodes to parse. + :return: A dictionary containing the extracted keys and values. + """ + extracted: dict[str, Any] = {} + for keyword in keywords: + key = keyword.arg + valNode = keyword.value + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val = getattr(valNode, "value", None) + if key is not None: + extracted[key] = val + return extracted + + +def formatAuthorList(rawAuthors: str) -> tomlkit.items.Array: + """Convert a comma-separated string of authors into a tomlkit multiline array of inline tables. + + :param rawAuthors: The raw authors string (e.g., "Author Name , Another"). + :return: A tomlkit.items.Array object containing inline tables. + """ + authorsList = tomlkit.array() + authorsList.multiline(True) + parts = [p.strip() for p in rawAuthors.split(",") if p.strip()] + + for part in parts: + m = re.match(r"^(.*?)\s*<(.*?)>$", part) + if m: + t = tomlkit.inline_table() + t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) + authorsList.append(t) + elif part: + t = tomlkit.inline_table() + t.update({"name": part, "email": ""}) + authorsList.append(t) + return authorsList + + +def cleanupPlaceholderAuthors(projectSection: dict[str, Any]) -> None: + """Remove NV Access placeholder entries from authors and maintainers fields in-place. + + :param projectSection: The project table/dictionary within the TOML structure. + :return: None + """ + for field in ["authors", "maintainers"]: + if field in projectSection and isinstance(projectSection[field], list): + tomlList = projectSection[field] + # Reverse loop to safely delete by index within tomlkit structure + for i in range(len(tomlList) - 1, -1, -1): + item = tomlList[i] + name = item.get("name", "") if hasattr(item, "get") else "" + if not name and isinstance(item, dict): + name = item.get("name", "") + + if str(name).strip().lower() in ["nv access", "nvaccess"]: + tomlList.pop(i) + + +def getBasePackageName(s: str) -> str: + """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). + + :param s: The raw dependency string. + :return: The normalized base package name in lowercase. + """ + m = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", s.strip()) + return m.group(0).lower() if m else s.strip().lower() + + +def replaceAstRange(tplLines: list[str], replacements: dict[tuple[int, int], str]) -> None: + """Apply AST line replacements on a line-by-line list in reverse order. + + :param tplLines: The list of lines representing the file content. + :param replacements: A dictionary mapping (start_line, end_line) tuples to the replacing string. + :return: None + """ + sortedRanges = sorted(replacements.keys(), key=lambda x: x[0], reverse=True) + for start, end in sortedRanges: + tplLines[start:end] = [replacements[(start, end)]] + + def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: """Recursively merges dictTpl into dictProj. @@ -86,24 +188,9 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict if varName == "addon_info": if isinstance(node.value, ast.Dict): - for keyNode, valNode in zip(node.value.keys, node.value.values): - if keyNode is None: - continue - key = getattr(keyNode, "value", None) - if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": - valNode = valNode.args[0] - val = getattr(valNode, "value", None) - if key is not None: - metadata[key] = val + metadata.update(parseAstDict(node.value)) elif isinstance(node.value, ast.Call) and getattr(node.value.func, "id", None) == "AddonInfo": - for keyword in node.value.keywords: - key = keyword.arg - valNode = keyword.value - if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": - valNode = valNode.args[0] - val = getattr(valNode, "value", None) - if key is not None: - metadata[key] = val + metadata.update(parseAstKeywords(node.value.keywords)) elif varName in topLevelVars: globalVars[varName] = ast.unparse(node.value) elif isinstance(node, ast.AnnAssign): @@ -153,25 +240,8 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict projData["project"]["urls"]["Repository"] = addonUrl # Build the maintainers multiline array using standard inline tables - authorsList = tomlkit.array() - authorsList.multiline(True) - if "addon_author" in metadata and metadata["addon_author"]: - rawAuthors = str(metadata["addon_author"]) - parts = [p.strip() for p in rawAuthors.split(",") if p.strip()] - - for part in parts: - m = re.match(r"^(.*?)\s*<(.*?)>$", part) - if m: - t = tomlkit.inline_table() - t.update({"name": m.group(1).strip(), "email": m.group(2).strip()}) - authorsList.append(t) - elif part: - t = tomlkit.inline_table() - t.update({"name": part, "email": ""}) - authorsList.append(t) - - projData["project"]["maintainers"] = authorsList + projData["project"]["maintainers"] = formatAuthorList(metadata["addon_author"]) if not dryRun: # Dump to string and sanitize indentation to strict tabs before writing @@ -219,37 +289,16 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict # 1. Conditional cleanup of NV Access placeholders # Only remove them if NV Access wasn't the original author of the add-on if not wasOriginallyNvaccess: - for field in ["authors", "maintainers"]: - if field in projectSection and isinstance(projectSection[field], list): - tomlList = projectSection[field] - # Reverse loop to safely delete by index within tomlkit structure - for i in range(len(tomlList) - 1, -1, -1): - item = tomlList[i] - name = item.get("name", "") if hasattr(item, "get") else "" - if not name and isinstance(item, dict): - name = item.get("name", "") - - if str(name).strip().lower() in ["nv access", "nvaccess"]: - tomlList.pop(i) + cleanupPlaceholderAuthors(projectSection) # 2. Smart merge of dependencies (Template layout and comments win) if "dependencies" in projectSection: tplDeps = projectSection["dependencies"] - - # Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.) - def getBase(s: str) -> str: - """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). - - :param s: The raw dependency string. - :return: The normalized base package name in lowercase. - """ - m = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", s.strip()) - return m.group(0).lower() if m else s.strip().lower() - tplBases = {getBase(d) for d in tplDeps} + tplBases = {getBasePackageName(d) for d in tplDeps} # Append custom user dependencies only if they are not already defined by the template for dep in projDeps: - base = getBase(dep) + base = getBasePackageName(dep) if base not in tplBases: tplDeps.append(dep) @@ -339,9 +388,7 @@ def mergeBuildvarsFile( f"{prefix}{indent}{key}: {typeStr} = {valExpression}\n" ) - sortedRanges = sorted(replacements.keys(), key=lambda x: x[0], reverse=True) - for start, end in sortedRanges: - tplLines[start:end] = [replacements[(start, end)]] + replaceAstRange(tplLines, replacements) if not dryRun: with pProj.open("w", encoding="utf-8") as f: @@ -442,8 +489,8 @@ def main() -> None: description="Non-destructive industrial update tool for NVDA Add-ons.", ) parser.add_argument( - "addonDir", - nargs="?", + "-ad", "--addon-dir", + dest="addonDir", default=None, help="Path to the root directory of the add-on to update (defaults to current directory).", ) @@ -460,7 +507,7 @@ def main() -> None: help="Simulate execution without modifying any files.", ) parser.add_argument( - "-sk", "--skip-backup", + "-s", "--skip-backup", dest="skipBackup", action="store_true", help="Disable safety automatic project backup.", From 905921056b4c40f1b6880779ab195bb8299f8571 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 21 Jul 2026 00:44:53 +0200 Subject: [PATCH 29/32] docs & refactor: update sync script, buildVars, and guide per code review * Script (`syncAddonWithTemplate.py`): * Replace stdout prints with structured logging for file output support * Refactor CLI argument parsing into a dedicated helper function * Generate sync report in Markdown format for better readability * Support tomlkit mutable sequences for author/maintainer checks * Remove global whitespace replacements on TOML output to avoid string corruption * Restore strictly tab-indented author/maintainer arrays via scoped block formatting * Replace naive text search with ast.Name verification to detect actual `os` module usage * Clean up trailing EOF whitespace * Configuration (`buildVars.py`): * Ensure single top-level import os insertion during AST-based merging when `os` is used * Documentation (`updatingExistingAddons.md`): * Remove duplicated instruction sections and fix broken Markdown code blocks * Defer unit testing documentation section to PR #42 --- syncAddonWithTemplate.py | 328 +++++++++++++++++++++++---------------- tests/__init__.py | 9 -- 2 files changed, 192 insertions(+), 145 deletions(-) delete mode 100644 tests/__init__.py diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 124958b..69ce4ee 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -3,23 +3,57 @@ # See the file COPYING for more details. from collections.abc import MutableMapping, MutableSequence -import re import argparse import ast +import logging import os +import re import shutil import subprocess import sys import tempfile - -# Built-in in Python 3.11+, fully standardized for Python 3.13 from datetime import datetime from pathlib import Path from typing import Any, cast import tomlkit -TOMLKIT_AVAILABLE: bool = True +logger = logging.getLogger("syncAddon") + + +def buildArgParser() -> argparse.ArgumentParser: + """Build and configure the command-line argument parser. + + :return: Configured ArgumentParser object. + """ + parser = argparse.ArgumentParser( + description="Non-destructive industrial update tool for NVDA Add-ons.", + ) + parser.add_argument( + "-ad", "--addon-dir", + dest="addonDir", + default=None, + help="Path to the root directory of the add-on to update (defaults to current directory).", + ) + parser.add_argument( + "-td", "--template-dir", + dest="templateDir", + default=None, + help="Path to a local directory containing the NVDA AddonTemplate to use instead of fetching it via Git.", + ) + parser.add_argument( + "-dr", "--dry-run", + dest="dryRun", + action="store_true", + help="Simulate execution without modifying any files.", + ) + parser.add_argument( + "-s", "--skip-backup", + dest="skipBackup", + action="store_true", + help="Disable safety automatic project backup.", + ) + return parser def parseAstDict(node: ast.Dict) -> dict[str, Any]: @@ -59,14 +93,27 @@ def parseAstKeywords(keywords: list[ast.keyword]) -> dict[str, Any]: return extracted +def usesOsModule(node: ast.AST) -> bool: + """Check recursively if an AST node contains an actual reference to the 'os' module. + + :param node: The AST node to inspect. + :return: True if the node references 'os', False otherwise. + """ + for child in ast.walk(node): + if isinstance(child, ast.Name) and child.id == "os": + return True + return False + + def formatAuthorList(rawAuthors: str) -> tomlkit.items.Array: - """Convert a comma-separated string of authors into a tomlkit multiline array of inline tables. + """Convert a comma-separated string of authors into a tomlkit multiline array. :param rawAuthors: The raw authors string (e.g., "Author Name , Another"). :return: A tomlkit.items.Array object containing inline tables. """ authorsList = tomlkit.array() authorsList.multiline(True) + parts = [p.strip() for p in rawAuthors.split(",") if p.strip()] for part in parts: @@ -79,9 +126,72 @@ def formatAuthorList(rawAuthors: str) -> tomlkit.items.Array: t = tomlkit.inline_table() t.update({"name": part, "email": ""}) authorsList.append(t) + return authorsList +def fixTomlIndentation(tomlText: str) -> str: + """Replace leading 4-space indentations with a tab inside maintainers/authors array blocks. + + :param tomlText: The raw TOML string generated by tomlkit. + :return: The TOML string with strictly enforced tab indentation. + """ + lines = tomlText.splitlines(keepends=True) + fixedLines = [] + inTargetArray = False + + for line in lines: + stripped = line.strip() + if re.match(r"^(?:maintainers|authors)\s*=\s*\[", stripped): + inTargetArray = True + fixedLines.append(line) + continue + + if inTargetArray: + if stripped == "]": + inTargetArray = False + fixedLines.append(line) + else: + fixedLines.append(re.sub(r"^ {4}", "\t", line)) + else: + fixedLines.append(line) + + return "".join(fixedLines) + + +def createPyprojectFromTemplate(templatePath: Path, metadata: dict[str, Any]) -> tomlkit.TOMLDocument: + """Create a new pyproject.toml document based on the official template, preserving tab indentation. + + :param templatePath: Path to the reference template pyproject.toml file. + :param metadata: Extracted metadata dictionary from legacy buildVars/manifest. + :return: A tomlkit TOMLDocument adhering to template tab formatting and populated with metadata. + """ + with templatePath.open("r", encoding="utf-8") as f: + doc = tomlkit.parse(f.read()) + + if "project" not in doc: + doc["project"] = tomlkit.table() + + projectSection = doc["project"] + + if "addon_name" in metadata and metadata["addon_name"]: + projectSection["name"] = metadata["addon_name"] + + if "addon_summary" in metadata and metadata["addon_summary"]: + projectSection["description"] = metadata["addon_summary"] + + addonUrl = str(metadata.get("addon_url", "")).strip() + if addonUrl: + if "urls" not in projectSection: + projectSection["urls"] = tomlkit.table() + projectSection["urls"]["Repository"] = addonUrl + + if "addon_author" in metadata and metadata["addon_author"]: + projectSection["maintainers"] = formatAuthorList(metadata["addon_author"]) + + return doc + + def cleanupPlaceholderAuthors(projectSection: dict[str, Any]) -> None: """Remove NV Access placeholder entries from authors and maintainers fields in-place. @@ -89,9 +199,8 @@ def cleanupPlaceholderAuthors(projectSection: dict[str, Any]) -> None: :return: None """ for field in ["authors", "maintainers"]: - if field in projectSection and isinstance(projectSection[field], list): + if field in projectSection and isinstance(projectSection[field], (list, MutableSequence)): tomlList = projectSection[field] - # Reverse loop to safely delete by index within tomlkit structure for i in range(len(tomlList) - 1, -1, -1): item = tomlList[i] name = item.get("name", "") if hasattr(item, "get") else "" @@ -127,9 +236,6 @@ def replaceAstRange(tplLines: list[str], replacements: dict[tuple[int, int], str def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: """Recursively merges dictTpl into dictProj. - Note: tomlkit returns custom table/array objects that behave like mappings/sequences but are not - instances of built-in `dict`/`list`, so we must detect by ABCs rather than concrete types. - :param dictProj: The original dictionary to be updated. :param dictTpl: The template dictionary whose values will be merged into dictProj. :return: The updated dictProj with merged values from dictTpl. @@ -150,11 +256,11 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st return dictProj -def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict[str, Any]]: - """Extract metadata from an old buildVars.py file safely using modern AST APIs. +def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict[str, tuple[ast.AST, str]]]: + """Extract metadata and raw assignment expressions along with AST nodes from buildVars.py safely. :param filePath: The path to the buildVars.py file. - :return: A tuple containing two dictionaries: metadata and globalVars. + :return: A tuple containing two dictionaries: metadata and globalVars mapping var_name -> (ast_node, unparsed_expr). """ p = Path(filePath) if not p.exists(): @@ -164,11 +270,11 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict try: tree = ast.parse(f.read()) except SyntaxError as e: - print(f"[-] Syntax error while reading {p}: {e}") + logger.error("Syntax error while reading %s: %s", p, e) return {}, {} metadata: dict[str, Any] = {} - globalVars: dict[str, Any] = {} + globalVars: dict[str, tuple[ast.AST, str]] = {} topLevelVars: set[str] = { "pythonSources", "excludedFiles", @@ -192,11 +298,11 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict elif isinstance(node.value, ast.Call) and getattr(node.value.func, "id", None) == "AddonInfo": metadata.update(parseAstKeywords(node.value.keywords)) elif varName in topLevelVars: - globalVars[varName] = ast.unparse(node.value) + globalVars[varName] = (node.value, ast.unparse(node.value)) elif isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name) and node.target.id in topLevelVars: if node.value is not None: - globalVars[node.target.id] = ast.unparse(node.value) + globalVars[node.target.id] = (node.value, ast.unparse(node.value)) return metadata, globalVars @@ -214,44 +320,20 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict pProj = Path(projPath) if not pTpl.exists(): - return "skipped (no template)" + return "skipped (no template found)" if not pProj.exists(): try: - with pTpl.open("r", encoding="utf-8") as f: - projData = tomlkit.parse(f.read()) - - if "project" not in projData: - projData["project"] = tomlkit.table() - - if "addon_name" in metadata and metadata["addon_name"]: - projData["project"]["name"] = metadata["addon_name"] - - if "addon_summary" in metadata and metadata["addon_summary"]: - projData["project"]["description"] = metadata["addon_summary"] - - # Extract the repository URL from buildVars metadata - # Always preserve the key even if it is empty ("") - addonUrl = str(metadata.get("addon_url", "")).strip() - - if "urls" not in projData["project"]: - projData["project"]["urls"] = tomlkit.table() - - projData["project"]["urls"]["Repository"] = addonUrl - - # Build the maintainers multiline array using standard inline tables - if "addon_author" in metadata and metadata["addon_author"]: - projData["project"]["maintainers"] = formatAuthorList(metadata["addon_author"]) - + projData = createPyprojectFromTemplate(pTpl, metadata) if not dryRun: - # Dump to string and sanitize indentation to strict tabs before writing - tomlOutput = tomlkit.dumps(projData) - tomlOutput = tomlOutput.replace(" ", "\t") - + tomlOutput = fixTomlIndentation(tomlkit.dumps(projData)) + pProj.parent.mkdir(parents=True, exist_ok=True) with pProj.open("w", encoding="utf-8") as f: f.write(tomlOutput) + logger.info("Created missing pyproject.toml at %s", pProj) return "created from template" except Exception as e: + logger.error("Failed to create pyproject.toml: %s", e) return f"failed to create from template ({str(e)})" try: @@ -260,11 +342,10 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict with pTpl.open("r", encoding="utf-8") as f: tplData = tomlkit.parse(f.read()) - # Check if NV Access was ALREADY the original author/maintainer of the project wasOriginallyNvaccess = False if "project" in projData: for field in ["authors", "maintainers"]: - if field in projData["project"] and isinstance(projData["project"][field], list): + if field in projData["project"] and isinstance(projData["project"][field], (list, MutableSequence)): for item in projData["project"][field]: name = item.get("name", "") if hasattr(item, "get") else "" if not name and isinstance(item, dict): @@ -273,40 +354,35 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict wasOriginallyNvaccess = True break - # Backup dependencies from project to merge them manually later projDeps = [] if "project" in projData and "dependencies" in projData["project"]: projDeps = list(projData["project"]["dependencies"]) - # Temporarily remove dependencies from project to let template comments win del projData["project"]["dependencies"] - # Execute the main structural merge mergedData = deepMergeDicts(cast(dict[str, Any], projData), cast(dict[str, Any], tplData)) if "project" in mergedData: projectSection = mergedData["project"] - # 1. Conditional cleanup of NV Access placeholders - # Only remove them if NV Access wasn't the original author of the add-on if not wasOriginallyNvaccess: cleanupPlaceholderAuthors(projectSection) - # 2. Smart merge of dependencies (Template layout and comments win) if "dependencies" in projectSection: tplDeps = projectSection["dependencies"] tplBases = {getBasePackageName(d) for d in tplDeps} - # Append custom user dependencies only if they are not already defined by the template for dep in projDeps: base = getBasePackageName(dep) if base not in tplBases: tplDeps.append(dep) if not dryRun: + tomlOutput = fixTomlIndentation(tomlkit.dumps(cast(tomlkit.TOMLDocument, mergedData))) with pProj.open("w", encoding="utf-8") as f: - f.write(tomlkit.dumps(cast(tomlkit.TOMLDocument, mergedData))) + f.write(tomlOutput) return "merged intelligently (tomlkit)" except Exception as e: + logger.error("Failed to merge pyproject.toml: %s", e) return f"failed to merge ({str(e)})" @@ -314,7 +390,7 @@ def mergeBuildvarsFile( projPath: str | Path, tplPath: str | Path, metadata: dict[str, Any], - globalVars: dict[str, Any], + globalVars: dict[str, tuple[ast.AST, str]], dryRun: bool = False, ) -> str: """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks. @@ -322,7 +398,7 @@ def mergeBuildvarsFile( :param projPath: Path to the existing buildVars.py file. :param tplPath: Path to the template buildVars.py file. :param metadata: Dictionary containing metadata values to update. - :param globalVars: Dictionary containing global variable values to update. + :param globalVars: Dictionary containing global variables mapping var_name -> (ast_node, unparsed_expr). :param dryRun: If True, simulate the merge without writing changes to disk. :return: A string indicating the result of the merge operation. """ @@ -342,6 +418,7 @@ def mergeBuildvarsFile( tplLines = tplContent.splitlines(keepends=True) replacements: dict[tuple[int, int], str] = {} + requiresOsImport = False for node in ast.walk(tree): if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "AddonInfo": @@ -353,7 +430,7 @@ def mergeBuildvarsFile( formattedVal = "None" elif isinstance(val, str): isTranslatable = key in ["addon_summary", "addon_description", "addon_changelog"] - formattedVal = f'_({val!r})' if isTranslatable else repr(val) + formattedVal = f"_({val!r})" if isTranslatable else repr(val) else: formattedVal = str(val) @@ -366,30 +443,37 @@ def mergeBuildvarsFile( target = node.targets[0] if isinstance(target, ast.Name) and target.id in globalVars: key = target.id - valExpression = globalVars[key] + valNode, valExpression = globalVars[key] + if usesOsModule(valNode): + requiresOsImport = True if node.end_lineno is not None: lineContent = tplLines[node.lineno - 1] indent = lineContent[: len(lineContent) - len(lineContent.lstrip())] - prefix = f"{indent}import os\n" if "os." in valExpression else "" replacements[(node.lineno - 1, node.end_lineno)] = ( - f"{prefix}{indent}{key} = {valExpression}\n" + f"{indent}{key} = {valExpression}\n" ) elif isinstance(node, ast.AnnAssign): if isinstance(node.target, ast.Name) and node.target.id in globalVars: key = node.target.id - valExpression = globalVars[key] + valNode, valExpression = globalVars[key] + if usesOsModule(valNode): + requiresOsImport = True if node.end_lineno is not None: lineContent = tplLines[node.lineno - 1] indent = lineContent[: len(lineContent) - len(lineContent.lstrip())] typeStr = ast.unparse(node.annotation) - prefix = f"{indent}import os\n" if "os." in valExpression else "" replacements[(node.lineno - 1, node.end_lineno)] = ( - f"{prefix}{indent}{key}: {typeStr} = {valExpression}\n" + f"{indent}{key}: {typeStr} = {valExpression}\n" ) replaceAstRange(tplLines, replacements) + if requiresOsImport: + hasOsImport = any("import os" in line for line in tplLines[:15]) + if not hasOsImport: + tplLines.insert(0, "import os\n") + if not dryRun: with pProj.open("w", encoding="utf-8") as f: f.writelines(tplLines) @@ -404,7 +488,7 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: :param dryRun: If True, simulate the sync without writing changes to disk. :return: None """ - print("[*] Synchronizing template machinery files...") + logger.info("Synchronizing template machinery files...") protectedElements = { "readme.md", "changelog.md", @@ -416,25 +500,23 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: ".ruff_cache", } - # Dynamically load custom ignore patterns from the target add-on directory if they exist ignoreFilePath = os.path.join(addonDir, ".addonmergeignore") if os.path.exists(ignoreFilePath): - print("[*] Reading local custom exclusions from .addonmergeignore...") + logger.info("Reading local custom exclusions from .addonmergeignore...") try: with open(ignoreFilePath, "r", encoding="utf-8") as f: for line in f: cleanLine = line.strip().lower() - # Exclude comments and empty lines if cleanLine and not cleanLine.startswith("#"): protectedElements.add(cleanLine) except Exception as e: - print(f"[-] Warning: Failed to parse .addonmergeignore ({e})") + logger.warning("Failed to parse .addonmergeignore (%s)", e) syncReport: list[str] = [] for item in os.listdir(tempDir): if item.lower() in protectedElements: - syncReport.append(f"{item} .................... skipped (protected scope)") + syncReport.append(f"- **{item}**: skipped (protected scope)") continue if item in ["buildVars.py", "pyproject.toml"]: @@ -448,15 +530,15 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: if not dryRun: os.makedirs(dstItem, exist_ok=True) shutil.copytree(srcItem, dstItem, dirs_exist_ok=True) - syncReport.append(f"{item}/ ................... merged safely") + syncReport.append(f"- **{item}/**: merged safely") else: if not dryRun: shutil.copy2(srcItem, dstItem) - syncReport.append(f"{item} .................... synchronized") + syncReport.append(f"- **{item}**: synchronized") except Exception as e: - syncReport.append(f"{item} .................... failed ({str(e)})") + syncReport.append(f"- **{item}**: failed ({str(e)})") - print("[*] Phase 4: Processing structural configuration merges...") + logger.info("Processing structural configuration merges...") templateBuildvars = os.path.join(tempDir, "buildVars.py") templatePyproject = os.path.join(tempDir, "pyproject.toml") @@ -469,113 +551,88 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: bvStatus = mergeBuildvarsFile(oldBuildvars, templateBuildvars, bvMeta, bvGlobals, dryRun) ppStatus = mergePyprojectToml(oldPyproject, templatePyproject, bvMeta, dryRun) - print("\n" + "=" * 50) - print("UPDATE REPORT") - print("=" * 50) - print(f"Add-on ....................... {addonName}") - print("\nTemplate synchronization:") + logger.info("=" * 50) + logger.info("UPDATE REPORT") + logger.info("=" * 50) + logger.info("Add-on: %s", addonName) + logger.info("\nTemplate synchronization:") for entry in syncReport: - print(f" - {entry}") - print( - f"\nConfiguration files:\n" - f" buildVars.py ............... {bvStatus}\n" - f" pyproject.toml ............. {ppStatus}", + logger.info(" %s", entry) + logger.info( + "\nConfiguration files:\n - **buildVars.py**: %s\n - **pyproject.toml**: %s", + bvStatus, + ppStatus, ) def main() -> None: """Execute main CLI entry point for the NVDA Add-on update tool.""" - parser = argparse.ArgumentParser( - description="Non-destructive industrial update tool for NVDA Add-ons.", - ) - parser.add_argument( - "-ad", "--addon-dir", - dest="addonDir", - default=None, - help="Path to the root directory of the add-on to update (defaults to current directory).", - ) - parser.add_argument( - "-td", "--template-dir", - dest="templateDir", - default=None, - help="Path to a local directory containing the NVDA AddonTemplate to use instead of fetching it via Git.", - ) - parser.add_argument( - "-dr", "--dry-run", - dest="dryRun", - action="store_true", - help="Simulate execution without modifying any files.", - ) - parser.add_argument( - "-s", "--skip-backup", - dest="skipBackup", - action="store_true", - help="Disable safety automatic project backup.", - ) + logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") + + parser = buildArgParser() args = parser.parse_args() addonDirInput = args.addonDir if addonDirInput: addonDir = os.path.abspath(addonDirInput) else: - # If executed from a subdirectory, walk upwards to find the add-on root. cwd = Path(os.getcwd()).resolve() addonRoot = next((p for p in (cwd, *cwd.parents) if (p / "buildVars.py").exists()), None) addonDir = str(addonRoot) if addonRoot is not None else str(cwd) - print("=== NVDA ADD-ON UPDATE TOOL ===") - print(f"[*] Target Directory: {addonDir}") + logger.info("=== NVDA ADD-ON UPDATE TOOL ===") + logger.info("Target Directory: %s", addonDir) oldBuildvars = os.path.join(addonDir, "buildVars.py") if not os.path.exists(oldBuildvars): - print(f"[-] Error: '{addonDir}' does not appear to be a valid NVDA Add-on (missing buildVars.py).") + logger.error("'%s' does not appear to be a valid NVDA Add-on (missing buildVars.py).", addonDir) if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) - print("[*] Phase 1: Analyzing existing project structure and metadata...") + logger.info("Phase 1: Analyzing existing project structure and metadata...") bvMeta, _ = extractBuildvarsMetadata(oldBuildvars) addonName = bvMeta.get("addon_name", os.path.basename(addonDir)) - print(f"[+] Target Add-on Identified: {addonName}") + logger.info("Target Add-on Identified: %s", addonName) if args.dryRun: - print("[!] RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") + logger.info("RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") - print("[*] Phase 2: Safety backup verification...") + logger.info("Phase 2: Safety backup verification...") if args.dryRun: - print("[*] Safety backup skipped (simulation mode active).") + logger.info("Safety backup skipped (simulation mode active).") elif args.skipBackup: - print("[!] Safety backup skipped (--skip-backup requested by user).") + logger.info("Safety backup skipped (--skip-backup requested by user).") else: backupDir = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - print(f"[*] Creating safety automatic backup in: {os.path.basename(backupDir)}...") + logger.info("Creating safety automatic backup in: %s...", os.path.basename(backupDir)) try: shutil.copytree( addonDir, backupDir, ignore=shutil.ignore_patterns(".git", "__pycache__", ".venv", "*_bak_*"), ) - print("[+] Backup created successfully.") + logger.info("Backup created successfully.") except Exception as e: - print(f"[-] Critical: Backup failed ({e}). Aborting update.") + logger.error("Critical: Backup failed (%s). Aborting update.", e) if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) if args.templateDir: templatePath = os.path.abspath(args.templateDir) - print(f"[*] Phase 3: Using local template directory: {templatePath}") + logger.info("Phase 3: Using local template directory: %s", templatePath) if not os.path.exists(os.path.join(templatePath, "buildVars.py")): - print("[-] Error: Provided template directory does not appear to be a valid NVDA AddonTemplate (missing buildVars.py).") + logger.error("Provided template directory does not appear to be a valid NVDA AddonTemplate (missing buildVars.py).") if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) runSynchronization(templatePath, addonDir, args.dryRun) else: - print("[*] Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + logger.info("Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") with tempfile.TemporaryDirectory() as tempDir: - print("[*] Cloning template into temporary workspace...") + logger.info("Cloning template into temporary workspace...") templateUrl = "https://github.com/nvaccess/AddonTemplate.git" try: @@ -585,11 +642,11 @@ def main() -> None: stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, ) - print("[+] Template cloned successfully.") + logger.info("Template cloned successfully.") except (subprocess.CalledProcessError, FileNotFoundError) as e: - print("[-] Error: Failed to execute git clone. Make sure Git is available in your PATH.") + logger.error("Failed to execute git clone. Make sure Git is available in your PATH.") if isinstance(e, subprocess.CalledProcessError) and e.stderr: - print(f"Details: {e.stderr.decode('utf-8', errors='ignore')}") + logger.error("Details: %s", e.stderr.decode("utf-8", errors="ignore")) if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) @@ -597,11 +654,10 @@ def main() -> None: runSynchronization(tempDir, addonDir, args.dryRun) if not args.dryRun: - print("\n[+] Project successfully updated. Workspace cleared.") + logger.info("Project successfully updated. Workspace cleared.") else: - print("\n[+] Simulation finished. Workspace cleared.") + logger.info("Simulation finished. Workspace cleared.") if __name__ == "__main__": main() - \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index ae5710c..0000000 --- a/tests/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# Copyright (C) 2026 NV Access Limited, Abdel -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -"""Unit test suite for the NVDA Add-on update and synchronization tools. - -This package contains automated tests to verify the AST-based configuration merges, -TOML updates, dependency parsing, and safety backup mechanisms of the update script. -""" From 835f3519a74ad466ffed884627120012b2e16788 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 24 Jul 2026 01:43:49 +0200 Subject: [PATCH 30/32] fix(sync): filter out legacy dev dependencies managed by dependency groups Prevent duplicate declarations and version conflicts during uv sync by filtering out legacy developer dependencies from project.dependencies if they are already managed by PEP 735 dependency groups. --- syncAddonWithTemplate.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 69ce4ee..9d2cfe0 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -371,9 +371,20 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict tplDeps = projectSection["dependencies"] tplBases = {getBasePackageName(d) for d in tplDeps} + # Collect all tooling package names managed by template dependency groups + groupBases: set[str] = set() + if "dependency-groups" in mergedData: + for grp in mergedData["dependency-groups"].values(): + if isinstance(grp, list): + for item in grp: + if isinstance(item, str): + groupBases.add(getBasePackageName(item)) + for dep in projDeps: base = getBasePackageName(dep) - if base not in tplBases: + # Only append to project.dependencies if not already present in template + # dependencies nor managed by PEP 735 dependency groups + if base not in tplBases and base not in groupBases: tplDeps.append(dep) if not dryRun: From 544ee407fed339c274f40c47e799f8841228fe5c Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 25 Jul 2026 01:28:24 +0200 Subject: [PATCH 31/32] build: strengthen dependencies validation, skip local egg-info, and bump tomlkit - Ensure `project.dependencies` type safety during pyproject TOML merging. - Skip `addonTemplate.egg-info` when synchronizing from a local template directory. - Update `tomlkit` pinned version in `pyproject.toml`. --- syncAddonWithTemplate.py | 34 ++++++++++++++++++++++++++++++---- uv.lock | 23 ++++++++++++++++++----- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 9d2cfe0..edc07e0 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -367,24 +367,48 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict if not wasOriginallyNvaccess: cleanupPlaceholderAuthors(projectSection) - if "dependencies" in projectSection: + # Filter and merge custom dependencies not managed by PEP 735 dependency groups + if isinstance(projectSection.get("dependencies"), (list, MutableSequence)): tplDeps = projectSection["dependencies"] tplBases = {getBasePackageName(d) for d in tplDeps} # Collect all tooling package names managed by template dependency groups groupBases: set[str] = set() - if "dependency-groups" in mergedData: + if "dependency-groups" in mergedData and isinstance(mergedData["dependency-groups"], MutableMapping): for grp in mergedData["dependency-groups"].values(): - if isinstance(grp, list): + if isinstance(grp, (list, MutableSequence)): for item in grp: if isinstance(item, str): groupBases.add(getBasePackageName(item)) + # Known template tooling packages across historical versions + # used to filter out deprecated tools (e.g., pre-commit) no longer present in the template. + legacyToolingBases = { + "pre-commit", + "scons", + "markdown", + "nh3", + "crowdin-api-client", + "lxml", + "mdx_truly_sane_lists", + "markdown-link-attr-modifier", + "mdx-gh-links", + "uv", + "ruff", + "prek", + "pyright", + } + for dep in projDeps: base = getBasePackageName(dep) + # Check if dependency is present in template dependencies or dependency groups + isInTemplate = base in tplBases or base in groupBases + # Check if dependency is a legacy tooling package dropped by the template + isDroppedTooling = base in legacyToolingBases and not isInTemplate + # Only append to project.dependencies if not already present in template # dependencies nor managed by PEP 735 dependency groups - if base not in tplBases and base not in groupBases: + if not isInTemplate and not isDroppedTooling: tplDeps.append(dep) if not dryRun: @@ -503,12 +527,14 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: protectedElements = { "readme.md", "changelog.md", + "addontemplate.egg-info", "addon", ".git", "__pycache__", ".venv", "docs", ".ruff_cache", + "tests", } ignoreFilePath = os.path.join(addonDir, ".addonmergeignore") diff --git a/uv.lock b/uv.lock index 4484ce7..8aa2402 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ source = { editable = "." } build = [ { name = "markdown" }, { name = "scons" }, + { name = "tomlkit" }, ] dev = [ { name = "crowdin-api-client" }, @@ -23,6 +24,7 @@ dev = [ { name = "pyright", extra = ["nodejs"] }, { name = "ruff" }, { name = "scons" }, + { name = "tomlkit" }, { name = "uv" }, ] l10n = [ @@ -46,6 +48,7 @@ lint = [ build = [ { name = "markdown", specifier = "==3.10" }, { name = "scons", specifier = "==4.10.1" }, + { name = "tomlkit", specifier = "==0.15.0" }, ] dev = [ { name = "crowdin-api-client", specifier = "==1.24.1" }, @@ -56,9 +59,10 @@ dev = [ { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, { name = "prek", specifier = "==0.4.8" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, + { name = "tomlkit", specifier = "==0.15.0" }, { name = "uv", specifier = "==0.11.15" }, ] l10n = [ @@ -71,7 +75,7 @@ l10n = [ ] lint = [ { name = "prek", specifier = "==0.4.8" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" }, { name = "ruff", specifier = "==0.14.5" }, { name = "uv", specifier = "==0.11.15" }, ] @@ -289,15 +293,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.407" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [package.optional-dependencies] @@ -355,6 +359,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 63e4927200f0dbdc3d14a91fe90d6038e5ddacf2 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 25 Jul 2026 12:25:02 +0200 Subject: [PATCH 32/32] fix(sync): deduplicate dependencies in group merge using package base name Update deepMergeDicts to prevent duplicate packages with conflicting versions when merging dependency sequences. Introduced mergeDependencyLists to compare packages by their normalized base name (ignoring version specifiers). This ensures existing tool versions from the template overwrite older ones while preserving custom project dependencies. --- .../updatingExistingAddons.md | 450 +++++++++++++----- syncAddonWithTemplate.py | 41 +- tests/__init__.py | 9 + 3 files changed, 386 insertions(+), 114 deletions(-) create mode 100644 tests/__init__.py diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 7e54872..06185b6 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,87 +1,332 @@ # Integrating the add-on template in your add-on -## Pre-requisites +## Pre-requisites for initial setup -1. Create a repository, for example on GitHub, providing readme and license files. -1. Clone the repository: +1. Create a repository, for example on GitHub, providing README and LICENSE files. + +2. Clone the repository to your local computer: + + ```sh + git clone https://github.com/{repoName}.git + ``` + +3. Go to the folder where your repository was cloned: + + ```sh + cd {repoFolder} + ``` + +4. In this folder, create an `addon` subfolder and store the code for your add-on. + +5. Commit your initial changes: + + ```sh + git add . + git commit -m "Initial commit" + ``` + +## Updating an Existing Add-on + +AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. + +You can merge the latest template changes into your repository instead of manually copying updated files. +This document explains both the recommended automated update procedure and the manual Git-based workflow. + +> [!NOTE] +> Updating from AddonTemplate only affects your project's infrastructure (build scripts, GitHub workflows, configuration files, etc.). +> It does **not** modify your add-on's source code. + +--- + +## Before you begin + +Before initiating any update workflow (automated or manual), please complete these safety checks: + +* **Check repository status**: + Ensure your working tree is clean. + + ```sh + git status + ``` + +* **Commit or stash**: + Save or stash any pending local modifications. + +* **Use a dedicated branch**: + It is highly recommended to perform the update on a separate, dedicated branch to isolate changes. + +--- + +## Recommended Method: Automated Update Using the Companion Tool + +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion utility script is included in AddonTemplate: `syncAddonWithTemplate.py`. + +This script automatically extracts your legacy project settings (such as the add-on name, summary, authors, and repository URL from `buildVars.py`) and merges them cleanly into the newly generated `pyproject.toml` file, while safely preserving empty values if certain metadata is not set. + +This automation ensures a seamless transition to the new template infrastructure without losing your original configuration. + +The script automatically supports updating two types of legacy add-ons: + +* **Legacy Structure (Dictionary-based without pyproject.toml):** + For older add-ons where `addon_info` was defined as a standard dictionary, the tool automatically migrates the metadata to the modern `AddonInfo` object structure. + It generates a new, fully populated `pyproject.toml` file matching the latest template standards, and synchronizes all infrastructure files. + +* **Modern Structure (AddonInfo-based):** + For newer add-ons that already use the `AddonInfo` object but need upstream template updates, the tool checks for any missing metadata keys in `buildVars.py` to insert them. + It safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. + +### Prerequisites + +Before running the tool, ensure your system meets the following requirements: + +* **Python**: + Version **3.13** or newer must be installed (matching the template's required Python version). + +* **Git**: + Git must be installed and available in your system `PATH`. + +* For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: + Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be installed in your global Python environment before execution. + This is necessary because legacy add-on repositories do not include this dependency yet, and it is not yet present by default in the template's stable `master` branch. + + To install or update `tomlkit` globally, run the following command in your terminal: ```sh - git clone https://github.com/{repoName}.git + python -m pip install -U tomlkit ``` -1. In the folder where your add-on repository is cloned, create an `addon` subfolder and store the code for your add-on. + *(Note: Windows users using the standard Python launcher can replace `python` with `py` if needed: `py -m pip install -U tomlkit`)* + +### Running the automated tool + +The script is highly flexible and supports two execution modes: + +1. **Standard Mode (No arguments):** + Run the script directly from the root of your repository or from any of its subdirectories. + It will automatically locate the project root by searching for `buildVars.py`. + + ```sh + uv run python syncAddonWithTemplate.py -ad . + ``` + +2. **Target Directory Mode (With argument):** + Run the script from any working directory by supplying the optional `addonDir` path (relative or absolute) pointing to the add-on repository you wish to update. + + ```sh + uv run python syncAddonWithTemplate.py -ad ../MyAddon + ``` + +> [!NOTE] +> Before applying any modifications, the script creates an untracked backup directory located next to the add-on folder named `_bak_`. +> This directory contains a copy of the entire project before the update, allowing you to restore the previous state manually if necessary. + +Once the update has completed, verify that the add-on still builds correctly: -1. Go to the folder where your repository was cloned: +```sh +uv sync +uv run scons +``` + +If everything builds successfully, remove the `_bak_` directory, stage and commit the updated infrastructure: + +```sh +git clean -f +git add . +git commit -m "chore: sync infrastructure with AddonTemplate" +``` + +### Using the Update Tool via Command Line + +The `syncAddonWithTemplate.py` script provides a non-destructive industrial update engine to align your local add-on repository layout with the latest structure of the official NVDA `AddonTemplate`. + +You can execute the script with various command-line arguments to customize the update workflow. + +#### Available Options + +| Short Flag | Long Argument | Description | Default Value | +| :--- | :--- | :--- | :--- | +| `-ad` | `--addon-dir` | Path to the root directory of the local add-on you want to update. If not specified, the script automatically walks up from your current directory to find `buildVars.py`. | Current working directory | +| `-td` | `--template-dir` | Path to a local clone/directory of the NVDA `AddonTemplate`. When provided, the tool skips fetching the template via Git and synchronizes directly using this local reference. | None (clones from GitHub) | +| `-dr` | `--dry-run` | Simulates the execution. It analyzes structure, logs planned changes, and builds reports without writing or modifying any file on disk. | Disabled | +| `-s` | `--skip-backup` | Disables the automatic creation of a timestamped backup directory (e.g., `addonName_bak_YYYYMMDD_HHMMSS`) before processing updates. | Disabled (Backup is created) | +| `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | + +#### Customizing Exclusions with `.addonmergeignore` + +Rather than modifying the `syncAddonWithTemplate.py` core source code or changing its internal `PROTECTED_ELEMENTS` array, the update tool includes a robust file-exclusion system driven by a local file named `.addonmergeignore`. + +This architectural design allows developers to cleanly decouple their project-specific freeze preferences from the update engine machinery. + +##### How to Use `.addonmergeignore` + +To declare custom exceptions, create a plain text file named `.addonmergeignore` and place it directly **at the root of your target add-on repository**. + +* Inside this file, list the names of the files or folders you want the tool to skip during synchronization. + +* You can write one pattern per line. + Empty lines and lines starting with `#` are automatically treated as comments and ignored. + +For instance, if you wish to prevent the synchronization process from overwriting your custom execution scripts or your exclusion mapping file itself, simply add them to the file: + +```text +# Freeze the synchronization script version +syncAddonWithTemplate.py +# Protect your local merge settings file from being replaced +.addonmergeignore +``` + +##### Crucial Requirements & Design Constraints + +1. **Case-Insensitivity:** + The update tool evaluates exclusions using a standardized, case-insensitive matching algorithm. + This ensures maximum cross-platform reliability (especially between Windows and Unix-like environments). + Since the script automatically normalizes all inputs to lowercase during execution, **you can write your rules using any casing you prefer** (e.g., `UpdateAddonFromTemplate.py` or `updateaddonfromtemplate.py` will both work perfectly). + +2. **File Location Requirement:** + The update engine always loads custom exclusions from the target add-on's root folder being updated. + Therefore, **the `.addonmergeignore` file must always reside inside the destination add-on directory**, even if you are executing the `syncAddonWithTemplate.py` script from a completely different directory or an external workspace. + +#### Usage Examples + +Depending on your workflow, the script can be executed either directly from within your add-on repository or from an external directory. + +##### 1. Standard Automatic Update + +Downloads the latest remote template, creates a safety backup of your repository, and non-destructively synchronizes the machinery files. + +* **Syntax A (Script inside the add-on repository):** ```sh - cd {repoFolder} + uv run python syncAddonWithTemplate.py -ad . ``` -1. Commit your changes: +* **Syntax B (Script outside the add-on repository):** ```sh - git add . - git commit -m "Initial commit" + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon ``` -1. Add the template as a remote: +##### 2. Updating from a Local Template Cache (Offline/Development) + +Useful when testing local modifications applied to the `AddonTemplate` or when working without an active internet connection. + +* **Syntax A (Script inside the add-on repository):** ```sh - git remote add template https://github.com/nvaccess/addonTemplate.git + uv run python syncAddonWithTemplate.py -ad . -td /path/to/local/AddonTemplate ``` -1. Fetch the add-on template: +* **Syntax B (Script outside the add-on repository):** ```sh - git fetch template + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` -## Updating an Existing Add-on +##### 3. Simulating Changes Safely (Dry Run) -As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workflows, and build system updates. +Analyzes structural layouts, evaluates configurations, reads the `.addonmergeignore` directives, and builds reports without writing anything to disk. -You can merge the latest template changes into your repository instead of manually copying updated files. +* **Syntax A (Script inside the add-on repository):** -This document explains the recommended update procedure. + ```sh + uv run python syncAddonWithTemplate.py -ad . --dry-run + ``` -> [!NOTE] -> Updating from AddonTemplate only affects your project's infrastructure (build scripts, GitHub workflows, configuration files, etc.). It does **not** modify your add-on's source code. +* **Syntax B (Script outside the add-on repository):** -## Before you begin + ```sh + uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon + ``` + +##### 4. Speeding Up with Backup Omission -Before updating your repository: +Target a project repository while skipping the automated safety backup creation phase to speed up execution. -- Ensure your working tree is clean. +* **Syntax A (Script inside the add-on repository):** ```sh - git status + uv run python syncAddonWithTemplate.py -ad . --skip-backup ``` -- Commit or stash any pending changes. +* **Syntax B (Script outside the add-on repository):** + + ```sh + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup + ``` -- It is recommended to perform the update on a dedicated branch. +##### 5. Run without Installation (`--with` option) -If anything goes wrong before the merge commit is created, if you haven't passed the `--squash- flag, you can safely cancel the operation using: +If you wish to execute the synchronization script directly without installing its mandatory dependencies (like `tomlkit`) into your current environment beforehand, you can request `uv` to fetch and expose the packages temporarily during the command lifetime by using the `--with` flag: ```sh -git merge --abort +uv run --with tomlkit python syncAddonWithTemplate.py -ad . +``` + +--- + +## Unit Testing the Add-on + +Ensuring your add-on behavior remains consistent during development and following template updates is done through unit testing. + +For the unit tests to execute successfully, the target module (such as `syncAddonWithTemplate.py`) must be located at the root of the repository as a sibling file to the `tests/` directory (at the same hierarchical level). This ensures that Python's module discovery can properly import the script when `unittest` runs from the project root. + +They can be validated quickly using `unittest` inside the virtual environment managed by `uv`. + +### Running the Test Suite + +To run the entire unit test suite with automatic test discovery and detailed output for every executed test case, use: + +```sh +uv run python -m unittest discover -s tests -v +``` + +Here is what each part of the command does: +* `uv run`: Executes the command within the virtual environment managed by `uv`. +* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. +* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. +* `-v`: Enables verbose output, displaying the status and description of each test method individually. + +To run a specific test module individually during development, specify its path: + +```sh +uv run python -m unittest -v tests/test_syncAddonWithTemplate.py ``` -## Adding the template repository +--- + +### Unit Test Overview (`test_syncAddonWithTemplate.py`) -If you have not already done so, add AddonTemplate as a remote: +The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST-based config merges, file parsing, and formatting behave predictably across project updates: + +* **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. +* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs. +* **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. +* **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. +* **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. +* **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. +* **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). + +--- + +## Alternative Method: Manual Update Using Git Merge + +If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. + +### Fetching the add-on template repository + +1. If you haven't done it yet, from your add-on repository, add the addonTemplate as a remote. ```sh -git remote add template https://github.com/nvaccess/AddonTemplate.git +git remote add template https://github.com/nvaccess/addonTemplate.git ``` -Then fetch the latest changes: +2. Fetch the template: ```sh git fetch template ``` -## Merging the latest template +### Merging the latest template Merge the latest version of AddonTemplate: @@ -89,14 +334,18 @@ Merge the latest version of AddonTemplate: git merge template/master --allow-unrelated-histories --squash ``` -The `--allow-unrelated-histories` option is required because your add-on repository and AddonTemplate do not share a common Git history. +* **Why `--allow-unrelated-histories`?** + This option is required because your add-on repository and AddonTemplate do not share a common Git history. -The `--squash` flags will add changes from the template as a unique commit, instead of several ones, what may be useful to keep a cleaner history on your repository. +* **Why `--squash`?** + This option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. + It compiles the template updates into a unique commit, which is useful to keep a cleaner history on your repository. At this stage, Git may report merge conflicts. - This is completely normal. +--- + ## Understanding merge conflicts During the merge, Git attempts to combine the contents of both repositories automatically. @@ -106,14 +355,14 @@ When Git cannot determine which version should be kept, it reports a merge confl A conflict does **not** mean that something went wrong. It simply means that some files require manual review. -## Resolving the merge +### Resolving the merge -### Using the restore command +#### Using the restore command The `restore` command can be used to update files on your working directory, i.e., the folder where your add-on repository was cloned. -The `--source` flag is used to determine where files to be restored can be found. +The `--source` option is used to determine where files to be restored can be found. -### Keep your add-on documentation +#### Keep your add-on documentation Your add-on documentation should not be replaced by the template. @@ -123,15 +372,14 @@ To keep your `.md` files from your add-on repository, ensuring they aren't repla git restore *.md --source=HEAD ``` -### Remove the template documentation +#### Remove the template documentation The `docs/` directory belongs to AddonTemplate itself. - It is not intended to become part of your add-on repository. Remove it: -``` +```sh git rm -r docs ``` @@ -141,54 +389,55 @@ Or use the restore command: git restore docs --source=HEAD ``` -### Resolve buildVars.py +#### Resolve `buildVars.py` `buildVars.py` usually contains merge conflicts because it includes both: -- information specific to your add-on; -- variables introduced by newer versions of AddonTemplate. +* information specific to your add-on; +* variables introduced by newer versions of AddonTemplate. Review the file carefully. In general: -- keep your add-on metadata; -- preserve your version number; -- keep your custom settings; -- add any new variables introduced by the template. +* keep your add-on metadata; +* preserve your version number; +* keep your custom settings; +* add any new variables introduced by the template. -### Resolve pyproject.toml +#### Resolve `pyproject.toml` `pyproject.toml` is another file that commonly requires manual review. Keep your project-specific configuration while incorporating any new settings required by the updated template. -### Other files +#### Other files -For most remaining files, the version provided by AddonTemplate is generally the correct one. +For most remaining infrastructure files, the version provided by AddonTemplate is generally the correct one. Typical examples include: -- `.github/` -- `.gitignore` -- `manifest.ini.tpl` -- `manifest-translated.ini.tpl` -- `site_scons/` -- `sconstruct` +* `.github/` +* `.gitignore` +* `manifest.ini.tpl` +* `manifest-translated.ini.tpl` +* `site_scons/` +* `sconstruct` Review any conflicts if necessary before completing the merge. -## Completing the merge +--- + +### Completing the merge Once all conflicts have been resolved, check if the add-on can be built properly: ```sh -uv sync # Update dependencies -uv run scons # Build the add-on +uv sync +uv run scons ``` - -If all is right, stage the modified files: +If everything builds successfully, stage the modified files: ```sh git add . @@ -197,13 +446,15 @@ git add . Then create the merge commit: ```sh -git commit +git commit -m "chore: sync infrastructure with AddonTemplate" ``` -## Summary +--- + +## Summary of File Actions | File or directory | Recommended action | -|-------------------|--------------------| +| :--- | :--- | | `README.md` | Keep the add-on version | | `CHANGELOG.md` | Keep the add-on version | | `docs/` | Remove | @@ -211,6 +462,8 @@ git commit | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | +--- + ## Troubleshooting ### I don't understand a merge conflict @@ -221,70 +474,45 @@ Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. +If you are unsure whether a change comes from your add-on or from AddonTemplate, compare the conflicting section with the latest version of AddonTemplate before resolving it. + ### I want to cancel the update -If you have not yet committed the merge, and you haven't passed the `--squash` flag to `git merge`, you can restore your repository to its previous state: +#### If using the Automated update: -```sh -git merge --abort -``` +Since the automated script creates an untracked timestamped full copy backup directory named `_bak_` before modifying any infrastructure files, you can restore your previous state manually from that folder if you decide not to keep the update. -If you passed the `--squash` flag, `git merge --abort` won't work. -In this case, you can use the restore command: +If you have already staged some changes, you can also discard them using: ```sh -git restore . --staged # Discard changes added to the staging area (after using `git add .`) +git restore . --staged ``` -```sh -git restore . --source=HEAD # Restores the working directory to the last commit made in your add-on repository -``` - -If you committed changes, you can use: +Then restore your working tree: ```sh -git reset --hard {cleanBranch} +git restore . --source=HEAD ``` ---- - -## Unit Testing the Add-on - -Ensuring your add-on behavior remains consistent during development and following template updates is done through unit testing. - -For the unit tests to execute successfully, the target module (such as `syncAddonWithTemplate.py`) must be located at the root of the repository as a sibling file to the `tests/` directory (at the same hierarchical level). This ensures that Python's module discovery can properly import the script when `unittest` runs from the project root. -They can be validated quickly using `unittest` inside the virtual environment managed by `uv`. - -### Running the Test Suite +#### If using the Manual update: -To run the entire unit test suite with automatic test discovery and detailed output for every executed test case, use: +If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: ```sh -uv run python -m unittest discover -s tests -v +git merge --abort ``` -Here is what each part of the command does: -* `uv run`: Executes the command within the virtual environment managed by `uv`. -* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. -* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. -* `-v`: Enables verbose output, displaying the status and description of each test method individually. +If you performed a squash merge, `git merge --abort` is no longer available because no merge state is recorded in Git. -To run a specific test module individually during development, specify its path: +In this case, restore your repository manually with: ```sh -uv run python -m unittest -v tests/test_syncAddonWithTemplate.py +git restore . --staged +git restore . --source=HEAD ``` ---- - -### Unit Test Overview (`test_syncAddonWithTemplate.py`) +If you have already committed the update and want to return to the previous state, you can reset your branch: -The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST-based config merges, file parsing, and formatting behave predictably across project updates: - -* **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. -* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs. -* **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. -* **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. -* **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. -* **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. -* **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). +```sh +git reset --hard {cleanBranch} +``` diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index edc07e0..4871224 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -233,6 +233,42 @@ def replaceAstRange(tplLines: list[str], replacements: dict[tuple[int, int], str tplLines[start:end] = [replacements[(start, end)]] +def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: + """Intelligently merge two dependency lists by updating package versions based on base names. + + Preserves custom user dependencies while replacing existing template tools with their newer versions. + + :param projList: The existing project's dependency list. + :param tplList: The template's dependency list. + :return: A merged list with updated versions and preserved custom items. + """ + # Map base package name -> index in projList + projIndexByBase: dict[str, int] = {} + for idx, item in enumerate(projList): + if isinstance(item, str): + base = getBasePackageName(item) + projIndexByBase[base] = idx + + merged = list(projList) + + for tplItem in tplList: + if isinstance(tplItem, str): + tplBase = getBasePackageName(tplItem) + if tplBase in projIndexByBase: + # Package already exists; update it to the template's version + targetIdx = projIndexByBase[tplBase] + merged[targetIdx] = tplItem + else: + # New package from template; append to list + merged.append(tplItem) + else: + # For non-string items (e.g., table inclusions like { include-group = "..." }) + if tplItem not in merged: + merged.append(tplItem) + + return merged + + def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: """Recursively merges dictTpl into dictProj. @@ -246,9 +282,8 @@ def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[st if isinstance(projVal, MutableMapping) and isinstance(value, MutableMapping): deepMergeDicts(projVal, value) elif isinstance(projVal, MutableSequence) and isinstance(value, MutableSequence): - for item in value: - if item not in projVal: - projVal.append(item) + # Intelligently merge dependency lists by package base name + dictProj[key] = mergeDependencyLists(list(projVal), list(value)) else: pass else: diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..ae5710c --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Unit test suite for the NVDA Add-on update and synchronization tools. + +This package contains automated tests to verify the AST-based configuration merges, +TOML updates, dependency parsing, and safety backup mechanisms of the update script. +"""