From 95415c8795205eef6ab9d1c076aeb9c8276fece0 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 9 Jul 2026 16:41:38 +0200 Subject: [PATCH 01/44] - 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. --- .../updatingExistingAddons.md | 43 ++ pyproject.toml | 6 +- updateAddon.py | 418 ++++++++++++++++++ 3 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 updateAddon.py diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4895903..6cca33f 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} ``` + +## Alternative: Automated Update Using the Companion Tool + +To streamline this process and avoid dealing with painful syntax errors or merge conflicts in metadata files, a companion update script is included within the template workspace at `updateAddon.py`. + +This script automatically handles the extraction of old metadata, cleans template placeholder data (such as removing `nvaccess` from authors lists if it's a third-party add-on), and merges `pyproject.toml` structures while fully preserving custom constraints and formatting. + +### Prerequisites for the script +Before running the tool, ensure your system meets the following requirements: +* **Python**: Version **3.11 or higher** must be installed on your PC. +* **Git**: Git must be installed and available in your system's PATH. + +### Running the automated tool + +The script is highly flexible and can be executed either from the root of your repository or from **any other directory** on your computer. + +1. Ensure your current branch is clean (`git status`). +2. Fetch the latest template changes from the remote: + +``` +git fetch Template +``` + +3. Run the automated script through `uv` (directly from the repository root): + +``` +uv run updateAddon.py +``` + +*Note: The script safely generates an un-tracked `_bak_` backup directory of your infrastructure files before applying any changes, allowing a seamless recovery if needed.* + +4. Verify that the add-on builds properly: +``` +uv sync +uv run scons +``` + +5. Stage the files and commit the update: + +``` +git add . +git commit -m "chore: sync infrastructure with AddonTemplate" +``` diff --git a/pyproject.toml b/pyproject.toml index ce3bbab..eb65ac7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,7 +35,9 @@ dependencies = [ "uv==0.11.15", "ruff==0.14.5", "pre-commit==4.2.0", - "pyright[nodejs]==1.1.407", + "pyright[nodejs]==1.1.411", + # Repository synchronization machinery + "tomlkit>=0.12.0", ] [project.urls] Repository = "https://github.com/nvaccess/addonTemplate" @@ -64,6 +66,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", + "updateAddon.py", ] [tool.ruff.format] @@ -102,6 +105,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "updateAddon.py", # 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/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 34b92eb9804af1579fc2fc7c364ba2a6d8f74291 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 02/44] 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 8c4f5480bbfa3736745a96d61c31b4709ffa78db 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 03/44] 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 57374165b21df192a706c3da8f3c60321b1adeab 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 04/44] 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 6cca33f..04432e0 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 de2db109420d7e00baa4405b7da5d5e8901bbe3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 9 Jul 2026 20:10:28 +0200 Subject: [PATCH 05/44] Reorganize documentation --- .../updatingExistingAddons.md | 131 ++++++++---------- 1 file changed, 58 insertions(+), 73 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 04432e0..1d270e0 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -24,18 +24,6 @@ git commit -m "Initial commit" ``` -1. Add the template as a remote: - - ```sh - git remote add template https://github.com/nvaccess/addonTemplate.git - ``` - -1. Fetch the add-on template: - - ```sh - git fetch template - ``` - ## Updating an Existing Add-on As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workflows, and build system updates. @@ -61,13 +49,54 @@ Before updating your repository: - It is recommended to perform the update on a dedicated branch. -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 anything goes wrong before a merge commit is created, if you haven't passed the `--squash- flag, you can safely cancel the operation using: ```sh git merge --abort ``` -## Adding the template repository +## Automated Update Using the Companion Tool + +To streamline this process and avoid dealing with painful syntax errors or merge conflicts in metadata files, a companion update script is included within the template workspace at `updateAddon.py`. + +This script automatically handles the extraction of old metadata, cleans template placeholder data (such as removing `nvaccess` from authors lists if it's a third-party add-on), and merges `pyproject.toml` structures while fully preserving custom constraints and formatting. + +### Prerequisites for the script + +Before running the tool, ensure your system meets the following requirements: + +* **Python**: Version **3.11 or higher** must be installed on your PC. +* **Git**: Git must be installed and available in your system's PATH. + +### Running the automated tool + +The script is highly flexible and can be executed either from the root of your repository or from **any other directory** on your computer. + +1. Run the automated script through `uv` (directly from the repository root): + +```sh +uv run updateAddon.py +``` + +*Note: The script safely generates an un-tracked `_bak_` backup directory of your infrastructure files before applying any changes, allowing a seamless recovery if needed.* + +1. Verify that the add-on builds properly: + +```sh +uv sync +uv run scons +``` + +1. Stage the files and commit the update: + +```sh +git add . +git commit -m "chore: sync infrastructure with AddonTemplate" +``` + +## Alternative: merge files manually + +### Adding the template repository If you have not already done so, add AddonTemplate as a remote: @@ -81,7 +110,7 @@ Then fetch the latest changes: git fetch template ``` -## Merging the latest template +### Merging the latest template Merge the latest version of AddonTemplate: @@ -97,7 +126,7 @@ At this stage, Git may report merge conflicts. This is completely normal. -## Understanding merge conflicts +### Understanding merge conflicts During the merge, Git attempts to combine the contents of both repositories automatically. @@ -106,14 +135,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. -### Keep your add-on documentation +#### Keep your add-on documentation Your add-on documentation should not be replaced by the template. @@ -123,7 +152,7 @@ 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. @@ -131,7 +160,7 @@ It is not intended to become part of your add-on repository. Remove it: -``` +```sh git rm -r docs ``` @@ -141,7 +170,7 @@ 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: @@ -157,13 +186,13 @@ In general: - 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. @@ -178,7 +207,7 @@ Typical examples include: 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: @@ -187,7 +216,6 @@ uv sync # Update dependencies uv run scons # Build the add-on ``` - If all is right, stage the modified files: ```sh @@ -200,7 +228,7 @@ Then create the merge commit: git commit ``` -## Summary +### Summary | File or directory | Recommended action | |-------------------|--------------------| @@ -211,9 +239,9 @@ git commit | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | -## Troubleshooting +### Troubleshooting -### I don't understand a merge conflict +#### I don't understand a merge conflict Merge conflicts are expected when updating from a newer version of AddonTemplate. @@ -221,7 +249,7 @@ Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. -### I want to cancel the update +#### 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: @@ -245,46 +273,3 @@ If you committed changes, you can use: ```sh git reset --hard {cleanBranch} ``` - -## Alternative: Automated Update Using the Companion Tool - -To streamline this process and avoid dealing with painful syntax errors or merge conflicts in metadata files, a companion update script is included within the template workspace at `updateAddon.py`. - -This script automatically handles the extraction of old metadata, cleans template placeholder data (such as removing `nvaccess` from authors lists if it's a third-party add-on), and merges `pyproject.toml` structures while fully preserving custom constraints and formatting. - -### Prerequisites for the script -Before running the tool, ensure your system meets the following requirements: -* **Python**: Version **3.11 or higher** must be installed on your PC. -* **Git**: Git must be installed and available in your system's PATH. - -### Running the automated tool - -The script is highly flexible and can be executed either from the root of your repository or from **any other directory** on your computer. - -1. Ensure your current branch is clean (`git status`). -2. Fetch the latest template changes from the remote: - -``` -git fetch Template -``` - -3. Run the automated script through `uv` (directly from the repository root): - -``` -uv run updateAddon.py -``` - -*Note: The script safely generates an un-tracked `_bak_` backup directory of your infrastructure files before applying any changes, allowing a seamless recovery if needed.* - -4. Verify that the add-on builds properly: -``` -uv sync -uv run scons -``` - -5. Stage the files and commit the update: - -``` -git add . -git commit -m "chore: sync infrastructure with AddonTemplate" -``` From fd829cf6897826c69c182b8e45051bb8ac07f028 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 9 Jul 2026 21:25:46 +0200 Subject: [PATCH 06/44] 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 b58f3c73ead12375197cef94046cb40d764d1038 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/44] 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 78aff70e9a0737feb6585c7997b21f26cbcbf9b9 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 10 Jul 2026 00:15:03 +0200 Subject: [PATCH 08/44] Overhaul and reorganize add-on update documentation - Completely restructure the guide to separate the recommended automated tool method from the manual Git merge workflow. - Integrate detailed instructions for the automated companion script (`updateAddon.py`). - Document the new template synchronization exclusions feature (`IGNORED_FILES`). - Improve overall clarity, formatting, and prerequisites for initial repository setup. --- .../updatingExistingAddons.md | 240 ++++++++++++------ 1 file changed, 162 insertions(+), 78 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 1d270e0..cb9ebe8 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,100 +1,162 @@ # 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. - ```sh - git clone https://github.com/{repoName}.git - ``` +2. Clone the repository: -1. In the folder where your add-on repository is cloned, create an `addon` subfolder and store the code for your add-on. + ```sh + cd {repoFolder} + git clone https://github.com/{repoName}.git + ``` -1. Go to the folder where your repository was cloned: +3. In the folder where your add-on repository is cloned, create an `addon` subfolder and store the code for your add-on. - ```sh - cd {repoFolder} - ``` +4. Go to the repository root: -1. Commit your changes: + ```sh + cd {repoFolder} + ``` - ```sh - git add . - git commit -m "Initial commit" - ``` +5. Commit your changes: + + ```sh + git add . + git commit -m "Initial commit" + ``` + +6. Add AddonTemplate as a remote: + + ```sh + git remote add template https://github.com/nvaccess/AddonTemplate.git + ``` + +7. Fetch the template: + + ```sh + git fetch template + ``` ## Updating an Existing Add-on -As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workflows, and build system updates. +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. +The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template. -This document explains the recommended update procedure. +If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document. > [!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 updating your repository: +## Recommended Method: Automated Update Using the Companion Tool -- Ensure your working tree is clean. +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included within the template workspace: `updateAddon.py`. - ```sh - git status - ``` +The script automatically handles the update process while preserving the information specific to your add-on. -- Commit or stash any pending changes. +Among other things, it: -- It is recommended to perform the update on a dedicated branch. +- updates infrastructure files from the latest version of AddonTemplate; +- preserves your add-on metadata; +- merges `buildVars.py` and `pyproject.toml`; +- removes placeholder metadata (such as `nvaccess` from the authors list when updating a third-party add-on); +- creates a backup of the original infrastructure files before applying any modifications. -If anything goes wrong before a merge commit is created, if you haven't passed the `--squash- flag, you can safely cancel the operation using: +### Prerequisites -```sh -git merge --abort -``` +Before running the tool, ensure your system meets the following requirements: -## Automated Update Using the Companion Tool +- **Python**: Version **3.11** or newer must be installed. +- **Git**: Git must be installed and available in your system `PATH`. -To streamline this process and avoid dealing with painful syntax errors or merge conflicts in metadata files, a companion update script is included within the template workspace at `updateAddon.py`. +### Running the automated tool -This script automatically handles the extraction of old metadata, cleans template placeholder data (such as removing `nvaccess` from authors lists if it's a third-party add-on), and merges `pyproject.toml` structures while fully preserving custom constraints and formatting. +The script is designed to be flexible and can be executed either from the root of your repository or from any other working directory. -### Prerequisites for the script +Before updating your repository: -Before running the tool, ensure your system meets the following requirements: +- Ensure your working tree is clean. -* **Python**: Version **3.11 or higher** must be installed on your PC. -* **Git**: Git must be installed and available in your system's PATH. + ```sh + git status + ``` -### Running the automated tool +- Commit or stash any pending changes. +- It is recommended to perform the update on a dedicated branch. -The script is highly flexible and can be executed either from the root of your repository or from **any other directory** on your computer. +Fetch the latest version of AddonTemplate: -1. Run the automated script through `uv` (directly from the repository root): +```sh +git fetch template +``` + +Run the update script: ```sh uv run updateAddon.py ``` -*Note: The script safely generates an un-tracked `_bak_` backup directory of your infrastructure files before applying any changes, allowing a seamless recovery if needed.* +> [!NOTE] +> Before modifying any infrastructure files, the script creates an untracked `_bak_` directory containing backups of every file it updates. If necessary, you can restore these files manually. -1. Verify that the add-on builds properly: +Once the update has completed, verify that the add-on still builds correctly: ```sh uv sync uv run scons ``` -1. Stage the files and commit the update: +If everything builds successfully, stage and commit the updated infrastructure: ```sh git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` -## Alternative: merge files manually +### Excluding specific template files + +By default, the script synchronizes every infrastructure file provided by AddonTemplate. + +If you want to preserve specific files from your repository (for example, a customized GitHub workflow), you can exclude them from synchronization. + +Open `updateAddon.py` and locate the `IGNORED_FILES` set near the beginning of the `main()` function: + +```python +IGNORED_FILES = { + os.path.join(".github", "workflows", "build_addon.yml").lower(), +} +``` + +Add any relative path from the template root to this set to prevent that file from being synchronized. + +--- + +## 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. + +### Before you begin + +Before updating your repository: + +- Ensure your working tree is clean. + + ```sh + git status + ``` + +- Commit or stash any pending changes. + +- It is recommended to perform the update on a dedicated branch. + +If anything goes wrong before the merge commit is created, and you haven't used the `--squash` option, you can safely cancel the operation using: + +```sh +git merge --abort +``` ### Adding the template repository @@ -120,43 +182,44 @@ 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. -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. +The `--squash` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. At this stage, Git may report merge conflicts. This is completely normal. -### Understanding merge conflicts +## Understanding merge conflicts During the merge, Git attempts to combine the contents of both repositories automatically. When Git cannot determine which version should be kept, it reports a merge conflict. A conflict does **not** mean that something went wrong. -It simply means that some files require manual review. +It simply means that some files require manual review. ### 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 `restore` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned. -#### Keep your add-on documentation +The `--source` option specifies where the files should be restored from. + +### Keep your add-on documentation Your add-on documentation should not be replaced by the template. -To keep your `.md` files from your add-on repository, ensuring they aren't replaced with files from the template, you can use the following command: +To restore the Markdown files from your repository and prevent them from being overwritten by the template, run: ```sh 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. +It is intended for developing AddonTemplate and should not become part of your add-on repository. Remove it: @@ -164,13 +227,13 @@ Remove it: git rm -r docs ``` -Or use the restore command: +Alternatively, if the directory already exists in your repository, you can restore your own version: ```sh git restore docs --source=HEAD ``` -#### Resolve buildVars.py +### Resolve `buildVars.py` `buildVars.py` usually contains merge conflicts because it includes both: @@ -184,17 +247,17 @@ In general: - keep your add-on metadata; - preserve your version number; - keep your custom settings; -- add any new variables introduced by the template. +- add any new variables introduced by the updated 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: @@ -207,28 +270,28 @@ Typical examples include: 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: +Once all conflicts have been resolved, verify that the add-on still builds correctly: ```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 . ``` -Then create the merge commit: +Then create the commit: ```sh -git commit +git commit -m "chore: sync infrastructure with AddonTemplate" ``` -### Summary +## Summary | File or directory | Recommended action | |-------------------|--------------------| @@ -239,9 +302,9 @@ git commit | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | -### Troubleshooting +## Troubleshooting -#### I don't understand a merge conflict +### I don't understand a merge conflict Merge conflicts are expected when updating from a newer version of AddonTemplate. @@ -249,26 +312,47 @@ Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. -#### I want to cancel the update +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 + +#### Automated update + +Since `updateAddon.py` creates an untracked `_bak_` directory before modifying any infrastructure files, you can restore the previous versions manually if you decide not to keep 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 you have already staged some changes, you can also discard them using: + +```sh +git restore . --staged +``` + +Then restore your working tree: + +```sh +git restore . --source=HEAD +``` + +#### Manual update + +If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: ```sh git merge --abort ``` -If you passed the `--squash` flag, `git merge --abort` won't work. -In this case, you can use the restore command: +If you performed a squash merge, `git merge --abort` is no longer available because no merge state is recorded. + +In this case, restore your repository with: ```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 +git restore . --source=HEAD ``` -If you committed changes, you can use: +If you have already committed the update and want to return to the previous state, you can reset your branch: ```sh git reset --hard {cleanBranch} From 58232f7aff818e4300e1375c755e6bd061d53594 Mon Sep 17 00:00:00 2001 From: abdel792 Date: Fri, 10 Jul 2026 08:34:50 +0200 Subject: [PATCH 09/44] 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 ff966ad71a5ffb0fe4571e28b239036c8079db53 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 10 Jul 2026 17:46:58 +0200 Subject: [PATCH 10/44] 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. --- .../updatingExistingAddons.md | 244 +++++++++--------- pyproject.toml | 6 +- updateAddon.py => updateAddonFromTemplate.py | 46 ++-- 3 files changed, 154 insertions(+), 142 deletions(-) rename updateAddon.py => updateAddonFromTemplate.py (92%) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index cb9ebe8..4353771 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,128 +1,128 @@ -# Integrating the add-on template in your add-on +# Integrating the add-on template in your add-on[cite: 5] -## Pre-requisites for initial setup +## Pre-requisites for initial setup[cite: 5] -1. Create a repository, for example on GitHub, providing README and LICENSE files. +1. Create a repository, for example on GitHub, providing README and LICENSE files.[cite: 5] -2. Clone the repository: +2. Clone the repository:[cite: 5] ```sh cd {repoFolder} git clone https://github.com/{repoName}.git ``` -3. In the folder where your add-on repository is cloned, create an `addon` subfolder and store the code for your add-on. +3. In the folder where your add-on repository is cloned, create an ```addon``` subfolder and store the code for your add-on.[cite: 5] -4. Go to the repository root: +4. Go to the repository root:[cite: 5] ```sh cd {repoFolder} ``` -5. Commit your changes: +5. Commit your changes:[cite: 5] ```sh git add . git commit -m "Initial commit" ``` -6. Add AddonTemplate as a remote: +6. Add AddonTemplate as a remote:[cite: 5] ```sh git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -7. Fetch the template: +7. Fetch the template:[cite: 5] ```sh git fetch template ``` -## Updating an Existing Add-on +## Updating an Existing Add-on[cite: 5] -AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. +AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates.[cite: 5] -The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template. +The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template.[cite: 5] -If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document. +If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document.[cite: 5] > [!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. +> 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.[cite: 5] --- -## Recommended Method: Automated Update Using the Companion Tool +## Recommended Method: Automated Update Using the Companion Tool[cite: 5] -To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included within the template workspace: `updateAddon.py`. +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included within the template workspace: ```updateAddonFromTemplate.py```.[cite: 5] -The script automatically handles the update process while preserving the information specific to your add-on. +The script automatically handles the update process while preserving the information specific to your add-on.[cite: 5] -Among other things, it: +Among other things, it:[cite: 5] -- updates infrastructure files from the latest version of AddonTemplate; -- preserves your add-on metadata; -- merges `buildVars.py` and `pyproject.toml`; -- removes placeholder metadata (such as `nvaccess` from the authors list when updating a third-party add-on); -- creates a backup of the original infrastructure files before applying any modifications. +- updates infrastructure files from the latest version of AddonTemplate;[cite: 5] +- preserves your add-on metadata;[cite: 5] +- merges ```buildVars.py``` and ```pyproject.toml```;[cite: 5] +- removes placeholder metadata (such as ```nvaccess``` from the authors list when updating a third-party add-on);[cite: 5] +- creates a backup of the original infrastructure files before applying any modifications.[cite: 5] -### Prerequisites +### Prerequisites[cite: 5] -Before running the tool, ensure your system meets the following requirements: +Before running the tool, ensure your system meets the following requirements:[cite: 5] -- **Python**: Version **3.11** or newer must be installed. -- **Git**: Git must be installed and available in your system `PATH`. +- **Python**: Version **3.11** or newer must be installed.[cite: 5] +- **Git**: Git must be installed and available in your system ```PATH```.[cite: 5] -### Running the automated tool +### Running the automated tool[cite: 5] -The script is designed to be flexible and can be executed either from the root of your repository or from any other working directory. +The script is designed to be flexible and can be executed either from the root of your repository or from any other working directory.[cite: 5] -Before updating your repository: +Before updating your repository:[cite: 5] -- Ensure your working tree is clean. +- Ensure your working tree is clean.[cite: 5] ```sh git status ``` -- Commit or stash any pending changes. -- It is recommended to perform the update on a dedicated branch. +- Commit or stash any pending changes.[cite: 5] +- It is recommended to perform the update on a dedicated branch.[cite: 5] -Fetch the latest version of AddonTemplate: +Fetch the latest version of AddonTemplate:[cite: 5] ```sh git fetch template ``` -Run the update script: +Run the update script:[cite: 5] ```sh -uv run updateAddon.py +uv run updateAddonFromTemplate.py ``` > [!NOTE] -> Before modifying any infrastructure files, the script creates an untracked `_bak_` directory containing backups of every file it updates. If necessary, you can restore these files manually. +> Before modifying any infrastructure files, the script creates an untracked ```_bak_``` directory containing backups of every file it updates. If necessary, you can restore these files manually.[cite: 5] -Once the update has completed, verify that the add-on still builds correctly: +Once the update has completed, verify that the add-on still builds correctly:[cite: 5] ```sh uv sync uv run scons ``` -If everything builds successfully, stage and commit the updated infrastructure: +If everything builds successfully, stage and commit the updated infrastructure:[cite: 5] ```sh git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` -### Excluding specific template files +### Excluding specific template files[cite: 5] -By default, the script synchronizes every infrastructure file provided by AddonTemplate. +By default, the script synchronizes every infrastructure file provided by AddonTemplate.[cite: 5] -If you want to preserve specific files from your repository (for example, a customized GitHub workflow), you can exclude them from synchronization. +If you want to preserve specific files from your repository (for example, a customized GitHub workflow), you can exclude them from synchronization.[cite: 5] -Open `updateAddon.py` and locate the `IGNORED_FILES` set near the beginning of the `main()` function: +Open ```updateAddonFromTemplate.py``` and locate the ```IGNORED_FILES``` set near the beginning of the ```main()``` function:[cite: 5] ```python IGNORED_FILES = { @@ -130,168 +130,168 @@ IGNORED_FILES = { } ``` -Add any relative path from the template root to this set to prevent that file from being synchronized. +Add any relative path from the template root to this set to prevent that file from being synchronized.[cite: 5] --- -## Alternative Method: Manual Update Using Git Merge +## Alternative Method: Manual Update Using Git Merge[cite: 5] -If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. +If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository.[cite: 5] -### Before you begin +### Before you begin[cite: 5] -Before updating your repository: +Before updating your repository:[cite: 5] -- Ensure your working tree is clean. +- Ensure your working tree is clean.[cite: 5] ```sh git status ``` -- Commit or stash any pending changes. +- Commit or stash any pending changes.[cite: 5] -- It is recommended to perform the update on a dedicated branch. +- It is recommended to perform the update on a dedicated branch.[cite: 5] -If anything goes wrong before the merge commit is created, and you haven't used the `--squash` option, you can safely cancel the operation using: +If anything goes wrong before the merge commit is created, and you haven't used the ```--squash``` option, you can safely cancel the operation using:[cite: 5] ```sh git merge --abort ``` -### Adding the template repository +### Adding the template repository[cite: 5] -If you have not already done so, add AddonTemplate as a remote: +If you have not already done so, add AddonTemplate as a remote:[cite: 5] ```sh git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -Then fetch the latest changes: +Then fetch the latest changes:[cite: 5] ```sh git fetch template ``` -### Merging the latest template +### Merging the latest template[cite: 5] -Merge the latest version of AddonTemplate: +Merge the latest version of AddonTemplate:[cite: 5] ```sh 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. +The ```--allow-unrelated-histories``` option is required because your add-on repository and AddonTemplate do not share a common Git history.[cite: 5] -The `--squash` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. +The ```--squash``` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner.[cite: 5] -At this stage, Git may report merge conflicts. +At this stage, Git may report merge conflicts.[cite: 5] -This is completely normal. +This is completely normal.[cite: 5] -## Understanding merge conflicts +## Understanding merge conflicts[cite: 5] -During the merge, Git attempts to combine the contents of both repositories automatically. +During the merge, Git attempts to combine the contents of both repositories automatically.[cite: 5] -When Git cannot determine which version should be kept, it reports a merge conflict. +When Git cannot determine which version should be kept, it reports a merge conflict.[cite: 5] -A conflict does **not** mean that something went wrong. +A conflict does **not** mean that something went wrong.[cite: 5] -It simply means that some files require manual review. -### Resolving the merge +It simply means that some files require manual review.[cite: 5] +### Resolving the merge[cite: 5] -### Using the restore command +### Using the restore command[cite: 5] -The `restore` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned. +The ```restore``` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned.[cite: 5] -The `--source` option specifies where the files should be restored from. +The ```--source``` option specifies where the files should be restored from.[cite: 5] -### Keep your add-on documentation +### Keep your add-on documentation[cite: 5] -Your add-on documentation should not be replaced by the template. +Your add-on documentation should not be replaced by the template.[cite: 5] -To restore the Markdown files from your repository and prevent them from being overwritten by the template, run: +To restore the Markdown files from your repository and prevent them from being overwritten by the template, run:[cite: 5] ```sh git restore *.md --source=HEAD ``` -### Remove the template documentation +### Remove the template documentation[cite: 5] -The `docs/` directory belongs to AddonTemplate itself. +The ```docs/``` directory belongs to AddonTemplate itself.[cite: 5] -It is intended for developing AddonTemplate and should not become part of your add-on repository. +It is intended for developing AddonTemplate and should not become part of your add-on repository.[cite: 5] -Remove it: +Remove it:[cite: 5] ```sh git rm -r docs ``` -Alternatively, if the directory already exists in your repository, you can restore your own version: +Alternatively, if the directory already exists in your repository, you can restore your own version:[cite: 5] ```sh git restore docs --source=HEAD ``` -### Resolve `buildVars.py` +### Resolve ```buildVars.py```[cite: 5] -`buildVars.py` usually contains merge conflicts because it includes both: +```buildVars.py``` usually contains merge conflicts because it includes both:[cite: 5] -- information specific to your add-on; -- variables introduced by newer versions of AddonTemplate. +- information specific to your add-on;[cite: 5] +- variables introduced by newer versions of AddonTemplate.[cite: 5] -Review the file carefully. +Review the file carefully.[cite: 5] -In general: +In general:[cite: 5] -- keep your add-on metadata; -- preserve your version number; -- keep your custom settings; -- add any new variables introduced by the updated template. +- keep your add-on metadata;[cite: 5] +- preserve your version number;[cite: 5] +- keep your custom settings;[cite: 5] +- add any new variables introduced by the updated template.[cite: 5] -### Resolve `pyproject.toml` +### Resolve ```pyproject.toml```[cite: 5] -`pyproject.toml` is another file that commonly requires manual review. +```pyproject.toml``` is another file that commonly requires manual review.[cite: 5] -Keep your project-specific configuration while incorporating any new settings required by the updated template. +Keep your project-specific configuration while incorporating any new settings required by the updated template.[cite: 5] -### Other files +### Other files[cite: 5] -For most remaining infrastructure 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.[cite: 5] -Typical examples include: +Typical examples include:[cite: 5] -- `.github/` -- `.gitignore` -- `manifest.ini.tpl` -- `manifest-translated.ini.tpl` -- `site_scons/` -- `sconstruct` +- ```.github/```[cite: 5] +- ```.gitignore```[cite: 5] +- ```manifest.ini.tpl```[cite: 5] +- ```manifest-translated.ini.tpl```[cite: 5] +- ```site_scons/```[cite: 5] +- ```sconstruct```[cite: 5] -Review any conflicts if necessary before completing the merge. +Review any conflicts if necessary before completing the merge.[cite: 5] -## Completing the merge +## Completing the merge[cite: 5] -Once all conflicts have been resolved, verify that the add-on still builds correctly: +Once all conflicts have been resolved, verify that the add-on still builds correctly:[cite: 5] ```sh uv sync uv run scons ``` -If everything builds successfully, stage the modified files: +If everything builds successfully, stage the modified files:[cite: 5] ```sh git add . ``` -Then create the commit: +Then create the commit:[cite: 5] ```sh git commit -m "chore: sync infrastructure with AddonTemplate" ``` -## Summary +## Summary[cite: 5] | File or directory | Recommended action | |-------------------|--------------------| @@ -302,47 +302,47 @@ git commit -m "chore: sync infrastructure with AddonTemplate" | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | -## Troubleshooting +## Troubleshooting[cite: 5] -### I don't understand a merge conflict +### I don't understand a merge conflict[cite: 5] -Merge conflicts are expected when updating from a newer version of AddonTemplate. +Merge conflicts are expected when updating from a newer version of AddonTemplate.[cite: 5] -Most conflicts occur in `buildVars.py` and `pyproject.toml`. +Most conflicts occur in ```buildVars.py``` and ```pyproject.toml```.[cite: 5] -Review the conflicting sections carefully and combine the changes from both versions. +Review the conflicting sections carefully and combine the changes from both versions.[cite: 5] -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. +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.[cite: 5] -### I want to cancel the update +### I want to cancel the update[cite: 5] -#### Automated update +#### Automated update[cite: 5] -Since `updateAddon.py` creates an untracked `_bak_` directory before modifying any infrastructure files, you can restore the previous versions manually if you decide not to keep the update. +Since ```updateAddonFromTemplate.py``` creates an untracked ```_bak_``` directory before modifying any infrastructure files, you can restore the previous versions manually if you decide not to keep the update.[cite: 5] -If you have already staged some changes, you can also discard them using: +If you have already staged some changes, you can also discard them using:[cite: 5] ```sh git restore . --staged ``` -Then restore your working tree: +Then restore your working tree:[cite: 5] ```sh git restore . --source=HEAD ``` -#### Manual update +#### Manual update[cite: 5] -If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: +If you have not yet committed the merge and **did not** use the ```--squash``` option, you can cancel it with:[cite: 5] ```sh git merge --abort ``` -If you performed a squash merge, `git merge --abort` is no longer available because no merge state is recorded. +If you performed a squash merge, ```git merge --abort``` is no longer available because no merge state is recorded.[cite: 5] -In this case, restore your repository with: +In this case, restore your repository with:[cite: 5] ```sh git restore . --staged @@ -352,7 +352,7 @@ git restore . --staged git restore . --source=HEAD ``` -If you have already committed the update and want to return to the previous state, you can reset your branch: +If you have already committed the update and want to return to the previous state, you can reset your branch:[cite: 5] ```sh git reset --hard {cleanBranch} diff --git a/pyproject.toml b/pyproject.toml index eb65ac7..9fa7aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "pre-commit==4.2.0", "pyright[nodejs]==1.1.411", # Repository synchronization machinery - "tomlkit>=0.12.0", + "tomlkit==0.13.0", ] [project.urls] Repository = "https://github.com/nvaccess/addonTemplate" @@ -66,7 +66,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", - "updateAddon.py", + "updateAddonFromTemplate.py", ] [tool.ruff.format] @@ -105,7 +105,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", - "updateAddon.py", + "updateAddonFromTemplate.py", # 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/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 d19a49657982878e9df72ae24d553a65aa561e8e 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 11/44] 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 c290ef2e3796dbaccd8a19d24416508fc21f7739 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 11 Jul 2026 08:56:45 +0200 Subject: [PATCH 12/44] docs: update updatingExistingAddons.md to match Copilot suggestions - Fix initial repository cloning paths by adding missing folder transition. - Document both standard and target directory execution modes (`addonDir`). - Align minimum Python requirements to 3.13 to match upstream template. - Clarify dynamic full-copy backup directory naming schema (`_bak_`). - Add comprehensive structural explanations for both legacy dictionary-based and modern AddonInfo-based add-on updates. - Note that `IGNORED_FILES` configuration supports excluding whole directories. --- .../updatingExistingAddons.md | 250 +++++++++--------- 1 file changed, 128 insertions(+), 122 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4353771..8c1926b 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,128 +1,133 @@ -# Integrating the add-on template in your add-on[cite: 5] +# Integrating the add-on template in your add-on -## Pre-requisites for initial setup[cite: 5] +## Pre-requisites for initial setup -1. Create a repository, for example on GitHub, providing README and LICENSE files.[cite: 5] +1. Create a repository, for example on GitHub, providing README and LICENSE files. -2. Clone the repository:[cite: 5] +2. Clone the repository: ```sh cd {repoFolder} git clone https://github.com/{repoName}.git + cd {repoName} ``` -3. In the folder where your add-on repository is cloned, create an ```addon``` subfolder and store the code for your add-on.[cite: 5] +3. In the folder where your add-on repository is cloned, create an ```addon``` subfolder and store the code for your add-on. -4. Go to the repository root:[cite: 5] +4. Go to the repository root: ```sh - cd {repoFolder} + cd {repoFolder}/{repoName} ``` -5. Commit your changes:[cite: 5] +5. Commit your changes: ```sh git add . git commit -m "Initial commit" ``` -6. Add AddonTemplate as a remote:[cite: 5] +6. Add AddonTemplate as a remote: ```sh git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -7. Fetch the template:[cite: 5] +7. Fetch the template: ```sh git fetch template ``` -## Updating an Existing Add-on[cite: 5] +## Updating an Existing Add-on -AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates.[cite: 5] +AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. -The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template.[cite: 5] +The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template. -If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document.[cite: 5] +If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document. > [!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.[cite: 5] +> 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. --- -## Recommended Method: Automated Update Using the Companion Tool[cite: 5] +## 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 update script is included within the template workspace: ```updateAddonFromTemplate.py```.[cite: 5] +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included within the template workspace: ```updateAddonFromTemplate.py```. -The script automatically handles the update process while preserving the information specific to your add-on.[cite: 5] +The script automatically handles the update process while intelligently supporting two types of legacy add-ons: -Among other things, it:[cite: 5] +- **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, generates a brand 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, and safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. -- updates infrastructure files from the latest version of AddonTemplate;[cite: 5] -- preserves your add-on metadata;[cite: 5] -- merges ```buildVars.py``` and ```pyproject.toml```;[cite: 5] -- removes placeholder metadata (such as ```nvaccess``` from the authors list when updating a third-party add-on);[cite: 5] -- creates a backup of the original infrastructure files before applying any modifications.[cite: 5] +### Prerequisites -### Prerequisites[cite: 5] +Before running the tool, ensure your system meets the following requirements: -Before running the tool, ensure your system meets the following requirements:[cite: 5] +- **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```. -- **Python**: Version **3.11** or newer must be installed.[cite: 5] -- **Git**: Git must be installed and available in your system ```PATH```.[cite: 5] +### Running the automated tool -### Running the automated tool[cite: 5] +The script is highly flexible and supports two execution modes: -The script is designed to be flexible and can be executed either from the root of your repository or from any other working directory.[cite: 5] +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`. +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. -Before updating your repository:[cite: 5] +Before updating your repository: -- Ensure your working tree is clean.[cite: 5] +- Ensure your working tree is clean. ```sh git status ``` -- Commit or stash any pending changes.[cite: 5] -- It is recommended to perform the update on a dedicated branch.[cite: 5] +- Commit or stash any pending changes. +- It is recommended to perform the update on a dedicated branch. -Fetch the latest version of AddonTemplate:[cite: 5] +Fetch the latest version of AddonTemplate: ```sh git fetch template ``` -Run the update script:[cite: 5] +Run the update script in **Standard Mode**: ```sh uv run updateAddonFromTemplate.py ``` +Alternatively, run the script in **Target Directory Mode** by specifying the path to your add-on folder: + +```sh +uv run updateAddonFromTemplate.py ../MyAddon +``` + > [!NOTE] -> Before modifying any infrastructure files, the script creates an untracked ```_bak_``` directory containing backups of every file it updates. If necessary, you can restore these files manually.[cite: 5] +> Before applying any modifications, the script creates an untracked backup directory located next to the add-on folder named `$_bak_```. This directory contains a full 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:[cite: 5] +Once the update has completed, verify that the add-on still builds correctly: ```sh uv sync uv run scons ``` -If everything builds successfully, stage and commit the updated infrastructure:[cite: 5] +If everything builds successfully, stage and commit the updated infrastructure: ```sh git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` -### Excluding specific template files[cite: 5] +### Excluding specific template files or directories -By default, the script synchronizes every infrastructure file provided by AddonTemplate.[cite: 5] +By default, the script synchronizes every infrastructure file provided by AddonTemplate. -If you want to preserve specific files from your repository (for example, a customized GitHub workflow), you can exclude them from synchronization.[cite: 5] +If you want to preserve specific components from your repository (for example, a customized GitHub workflow or directory), you can exclude them from synchronization. -Open ```updateAddonFromTemplate.py``` and locate the ```IGNORED_FILES``` set near the beginning of the ```main()``` function:[cite: 5] +Open ```updateAddonFromTemplate.py``` and locate the ```IGNORED_FILES``` set near the beginning of the ```main()``` function: ```python IGNORED_FILES = { @@ -130,168 +135,169 @@ IGNORED_FILES = { } ``` -Add any relative path from the template root to this set to prevent that file from being synchronized.[cite: 5] +Add any template-relative path to this set to prevent the corresponding file or directory from being synchronized. --- -## Alternative Method: Manual Update Using Git Merge[cite: 5] +## 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.[cite: 5] +If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. -### Before you begin[cite: 5] +### Before you begin -Before updating your repository:[cite: 5] +Before updating your repository: -- Ensure your working tree is clean.[cite: 5] +- Ensure your working tree is clean. ```sh git status ``` -- Commit or stash any pending changes.[cite: 5] +- Commit or stash any pending changes. -- It is recommended to perform the update on a dedicated branch.[cite: 5] +- It is recommended to perform the update on a dedicated branch. -If anything goes wrong before the merge commit is created, and you haven't used the ```--squash``` option, you can safely cancel the operation using:[cite: 5] +If anything goes wrong before the merge commit is created, and you haven't used the ```--squash``` option, you can safely cancel the operation using: ```sh git merge --abort ``` -### Adding the template repository[cite: 5] +### Adding the template repository -If you have not already done so, add AddonTemplate as a remote:[cite: 5] +If you have not already done so, add AddonTemplate as a remote: ```sh git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -Then fetch the latest changes:[cite: 5] +Then fetch the latest changes: ```sh git fetch template ``` -### Merging the latest template[cite: 5] +### Merging the latest template -Merge the latest version of AddonTemplate:[cite: 5] +Merge the latest version of AddonTemplate: ```sh 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.[cite: 5] +The ```--allow-unrelated-histories``` option is required because your add-on repository and AddonTemplate do not share a common Git history. + +The ```--squash``` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. -The ```--squash``` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner.[cite: 5] +At this stage, Git may report merge conflicts. -At this stage, Git may report merge conflicts.[cite: 5] +This is completely normal. -This is completely normal.[cite: 5] +## Understanding merge conflicts -## Understanding merge conflicts[cite: 5] +During the merge, Git attempts to combine the contents of both repositories automatically. -During the merge, Git attempts to combine the contents of both repositories automatically.[cite: 5] +When Git cannot determine which version should be kept, it reports a merge conflict. -When Git cannot determine which version should be kept, it reports a merge conflict.[cite: 5] +A conflict does **not** mean that something went wrong. -A conflict does **not** mean that something went wrong.[cite: 5] +It simply means that some files require manual review. -It simply means that some files require manual review.[cite: 5] -### Resolving the merge[cite: 5] +### Resolving the merge -### Using the restore command[cite: 5] +### Using the restore command -The ```restore``` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned.[cite: 5] +The ```restore``` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned. -The ```--source``` option specifies where the files should be restored from.[cite: 5] +The ```--source``` option specifies where the files should be restored from. -### Keep your add-on documentation[cite: 5] +### Keep your add-on documentation -Your add-on documentation should not be replaced by the template.[cite: 5] +Your add-on documentation should not be replaced by the template. -To restore the Markdown files from your repository and prevent them from being overwritten by the template, run:[cite: 5] +To restore the Markdown files from your repository and prevent them from being overwritten by the template, run: ```sh git restore *.md --source=HEAD ``` -### Remove the template documentation[cite: 5] +### Remove the template documentation -The ```docs/``` directory belongs to AddonTemplate itself.[cite: 5] +The ```docs/``` directory belongs to AddonTemplate itself. -It is intended for developing AddonTemplate and should not become part of your add-on repository.[cite: 5] +It is intended for developing AddonTemplate and should not become part of your add-on repository. -Remove it:[cite: 5] +Remove it: ```sh git rm -r docs ``` -Alternatively, if the directory already exists in your repository, you can restore your own version:[cite: 5] +Alternatively, if the directory already exists in your repository, you can restore your own version: ```sh git restore docs --source=HEAD ``` -### Resolve ```buildVars.py```[cite: 5] +### Resolve ```buildVars.py``` -```buildVars.py``` usually contains merge conflicts because it includes both:[cite: 5] +```buildVars.py``` usually contains merge conflicts because it includes both: -- information specific to your add-on;[cite: 5] -- variables introduced by newer versions of AddonTemplate.[cite: 5] +- information specific to your add-on; +- variables introduced by newer versions of AddonTemplate. -Review the file carefully.[cite: 5] +Review the file carefully. -In general:[cite: 5] +In general: -- keep your add-on metadata;[cite: 5] -- preserve your version number;[cite: 5] -- keep your custom settings;[cite: 5] -- add any new variables introduced by the updated template.[cite: 5] +- keep your add-on metadata; +- preserve your version number; +- keep your custom settings; +- add any new variables introduced by the updated template. -### Resolve ```pyproject.toml```[cite: 5] +### Resolve ```pyproject.toml``` -```pyproject.toml``` is another file that commonly requires manual review.[cite: 5] +```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.[cite: 5] +Keep your project-specific configuration while incorporating any new settings required by the updated template. -### Other files[cite: 5] +### Other files -For most remaining infrastructure files, the version provided by AddonTemplate is generally the correct one.[cite: 5] +For most remaining infrastructure files, the version provided by AddonTemplate is generally the correct one. -Typical examples include:[cite: 5] +Typical examples include: -- ```.github/```[cite: 5] -- ```.gitignore```[cite: 5] -- ```manifest.ini.tpl```[cite: 5] -- ```manifest-translated.ini.tpl```[cite: 5] -- ```site_scons/```[cite: 5] -- ```sconstruct```[cite: 5] +- ```.github/``` +- ```.gitignore``` +- ```manifest.ini.tpl``` +- ```manifest-translated.ini.tpl``` +- ```site_scons/``` +- ```sconstruct``` -Review any conflicts if necessary before completing the merge.[cite: 5] +Review any conflicts if necessary before completing the merge. -## Completing the merge[cite: 5] +## Completing the merge -Once all conflicts have been resolved, verify that the add-on still builds correctly:[cite: 5] +Once all conflicts have been resolved, verify that the add-on still builds correctly: ```sh uv sync uv run scons ``` -If everything builds successfully, stage the modified files:[cite: 5] +If everything builds successfully, stage the modified files: ```sh git add . ``` -Then create the commit:[cite: 5] +Then create the commit: ```sh git commit -m "chore: sync infrastructure with AddonTemplate" ``` -## Summary[cite: 5] +## Summary | File or directory | Recommended action | |-------------------|--------------------| @@ -302,47 +308,47 @@ git commit -m "chore: sync infrastructure with AddonTemplate" | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | -## Troubleshooting[cite: 5] +## Troubleshooting -### I don't understand a merge conflict[cite: 5] +### I don't understand a merge conflict -Merge conflicts are expected when updating from a newer version of AddonTemplate.[cite: 5] +Merge conflicts are expected when updating from a newer version of AddonTemplate. -Most conflicts occur in ```buildVars.py``` and ```pyproject.toml```.[cite: 5] +Most conflicts occur in ```buildVars.py``` and ```pyproject.toml```. -Review the conflicting sections carefully and combine the changes from both versions.[cite: 5] +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.[cite: 5] +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[cite: 5] +### I want to cancel the update -#### Automated update[cite: 5] +#### Automated update -Since ```updateAddonFromTemplate.py``` creates an untracked ```_bak_``` directory before modifying any infrastructure files, you can restore the previous versions manually if you decide not to keep the update.[cite: 5] +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 have already staged some changes, you can also discard them using:[cite: 5] +If you have already staged some changes, you can also discard them using: ```sh git restore . --staged ``` -Then restore your working tree:[cite: 5] +Then restore your working tree: ```sh git restore . --source=HEAD ``` -#### Manual update[cite: 5] +#### Manual update -If you have not yet committed the merge and **did not** use the ```--squash``` option, you can cancel it with:[cite: 5] +If you have not yet committed the merge and **did not** use the ```--squash``` option, you can cancel it with: ```sh git merge --abort ``` -If you performed a squash merge, ```git merge --abort``` is no longer available because no merge state is recorded.[cite: 5] +If you performed a squash merge, ```git merge --abort``` is no longer available because no merge state is recorded. -In this case, restore your repository with:[cite: 5] +In this case, restore your repository with: ```sh git restore . --staged @@ -352,7 +358,7 @@ git restore . --staged git restore . --source=HEAD ``` -If you have already committed the update and want to return to the previous state, you can reset your branch:[cite: 5] +If you have already committed the update and want to return to the previous state, you can reset your branch: ```sh git reset --hard {cleanBranch} From 04483424fe5de7fb4c5ed640a6f80464e1274766 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 11 Jul 2026 10:54:09 +0200 Subject: [PATCH 13/44] docs: apply adjustments, fix syntax and refine typography in updatingExistingAddons.md - Fix broken Markdown syntax for the backup directory naming template. - Remove redundant folder transition step from the initial setup guide. - Refine typography and adopt a neutral technical tone by removing promotional phrasing. - Apply overall documentation adjustments to improve layout and workflow clarity. --- .../updatingExistingAddons.md | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 8c1926b..4ac7b83 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -14,26 +14,20 @@ 3. In the folder where your add-on repository is cloned, create an ```addon``` subfolder and store the code for your add-on. -4. Go to the repository root: - - ```sh - cd {repoFolder}/{repoName} - ``` - -5. Commit your changes: +4. Commit your changes: ```sh git add . git commit -m "Initial commit" ``` -6. Add AddonTemplate as a remote: +5. Add AddonTemplate as a remote: ```sh git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -7. Fetch the template: +6. Fetch the template: ```sh git fetch template @@ -54,11 +48,11 @@ If you prefer to manage the update process yourself, a fully manual Git-based wo ## 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 update script is included within the template workspace: ```updateAddonFromTemplate.py```. +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included in AddonTemplate: ```updateAddonFromTemplate.py```. -The script automatically handles the update process while intelligently supporting two types of legacy add-ons: +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, generates a brand new, fully populated `pyproject.toml` file matching the latest template standards, and synchronizes all infrastructure files. +- **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, 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, and safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. ### Prerequisites @@ -86,12 +80,6 @@ Before updating your repository: - Commit or stash any pending changes. - It is recommended to perform the update on a dedicated branch. -Fetch the latest version of AddonTemplate: - -```sh -git fetch template -``` - Run the update script in **Standard Mode**: ```sh @@ -105,7 +93,7 @@ uv run updateAddonFromTemplate.py ../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 full copy of the entire project before the update, allowing you to restore the previous state manually if necessary. +> 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: @@ -324,7 +312,7 @@ If you are unsure whether a change comes from your add-on or from AddonTemplate, #### Automated update -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. +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 have already staged some changes, you can also discard them using: From dc81d3ffa86012cdbb4efa0551052fc6738e951e Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 11 Jul 2026 23:29:55 +0200 Subject: [PATCH 14/44] 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 af7419c0409f43137aab82dd0f1f24b241a0a924 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 15/44] 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 3c9778f1bcba3028726fb63018a2ce1b76339ae8 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 12 Jul 2026 08:35:57 +0200 Subject: [PATCH 16/44] docs: add tomlkit dependency installation to script prerequisites Update the prerequisites section in updatingExistingAddons.md to document that `tomlkit` must be installed globally before running `updateAddonFromTemplate.py`. This ensures users don't encounter missing module errors when executing the script on legacy repositories or before the upstream template merges the new dependency. --- docs/managementFromGit/updatingExistingAddons.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4ac7b83..3780449 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -59,8 +59,16 @@ The script automatically supports updating two types of legacy add-ons: 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```. +- **Python**: Version **3.13** or newer must be installed (matching the template's required Python version)[cite: 1]. +- **Git**: Git must be installed and available in your system ```PATH```[cite: 1]. +- **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 + python -m pip install -U tomlkit + ``` + *(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 From b3febf477d8bcb11103d78e831082f9bb17d8258 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 06:37:32 +0000 Subject: [PATCH 17/44] Pre-commit auto-fix --- 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 3780449..07b1fbc 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -64,7 +64,7 @@ Before running the tool, ensure your system meets the following requirements: - **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 python -m pip install -U tomlkit ``` From c749d395d5b60fa697d573fe1774b47b254eb9d3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 12 Jul 2026 09:10:40 +0200 Subject: [PATCH 18/44] 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 16a2d9e0fafd5fe578a082a29cf24d9218b448c9 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 19/44] 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 1cb6c09e41f6be3af42077df98b450f7e1db36bd Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 12 Jul 2026 10:55:08 +0200 Subject: [PATCH 20/44] 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 e74624b58a52b5579dadef5902c6b16b0a9857c5 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 21/44] 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 bb5b33a9501e6d67703048103adc715f63411015 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 00:07:08 +0200 Subject: [PATCH 22/44] 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 c3623bdf8ba97cc7b43dc2f007b3e05a21e5938e 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 23/44] 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 f2ad20ea6bc42c4397dfdd960232425720bfded1 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 14:13:40 +0200 Subject: [PATCH 24/44] 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 ec5345862a80cc56f744c3364b5e404bf33d269c 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 25/44] 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 27e2aacbdf5d47a8f33e9b3831cf2397f2c78106 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 13 Jul 2026 22:01:00 +0200 Subject: [PATCH 26/44] 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 2ed8b893ecb310129bb431a339ae9e5af425d4fb Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 06:39:58 +0200 Subject: [PATCH 27/44] 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 d074f9f5302cb3e98f08e1fd506b9fb398d0c02d Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 07:00:35 +0200 Subject: [PATCH 28/44] 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 e13295b00803e076af822a9d5b4036d1fe7c072f Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 11:41:25 +0200 Subject: [PATCH 29/44] 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. --- .../updatingExistingAddons.md | 106 ++++++++++++++++-- updateAddonFromTemplate.py | 6 +- 2 files changed, 97 insertions(+), 15 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 07b1fbc..4974580 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -59,8 +59,8 @@ The script automatically supports updating two types of legacy add-ons: 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)[cite: 1]. -- **Git**: Git must be installed and available in your system ```PATH```[cite: 1]. +- **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```. - **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: @@ -117,21 +117,104 @@ git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` -### Excluding specific template files or directories +### Using the Update Tool via Command Line -By default, the script synchronizes every infrastructure file provided by AddonTemplate. +The `updateAddonFromTemplate.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`. -If you want to preserve specific components from your repository (for example, a customized GitHub workflow or directory), you can exclude them from synchronization. +You can execute the script with various command-line arguments to customize the update workflow. -Open ```updateAddonFromTemplate.py``` and locate the ```IGNORED_FILES``` set near the beginning of the ```main()``` function: +#### Available Options -```python -IGNORED_FILES = { - os.path.join(".github", "workflows", "build_addon.yml").lower(), -} +| 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 `updateAddonFromTemplate.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: +``` +# Freeze the synchronization script version +updateaddonfromtemplate.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 `updateAddonFromTemplate.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):** +``` +python updateAddonFromTemplate.py ``` -Add any template-relative path to this set to prevent the corresponding file or directory from being synchronized. +* **Syntax B (Script outside the add-on repository):** +``` +python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon +``` + +##### 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):** +``` +python updateAddonFromTemplate.py -td /path/to/local/AddonTemplate +``` + +* **Syntax B (Script outside the add-on repository):** +``` +python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate +``` + +##### 3. Simulating Changes Safely (Dry Run) +Analyzes structural layouts, evaluates configurations, reads the `.addonmergeignore` directives, and builds reports without writing anything to disk. + +* **Syntax A (Script inside the add-on repository):** +``` +python updateAddonFromTemplate.py --dry-run +``` + +* **Syntax B (Script outside the add-on repository):** +``` +python /path/to/updateAddonFromTemplate.py --dry-run -ad /path/to/my-nvda-addon +``` + +##### 4. Speeding Up with Backup Omission +Target a project repository while skipping the automated safety backup creation phase to speed up execution. + +* **Syntax A (Script inside the add-on repository):** +``` +python updateAddonFromTemplate.py --skip-backup +``` + +* **Syntax B (Script outside the add-on repository):** +``` +python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-backup +``` --- @@ -359,3 +442,4 @@ If you have already committed the update and want to return to the previous stat ```sh git reset --hard {cleanBranch} ``` + 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 b702ea64317dd36953dc5d21dd791981cbf6e712 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 14:12:32 +0200 Subject: [PATCH 30/44] Resolve merge conflicts with recent master changes --- .github/workflows/build_addon.yml | 2 +- .pre-commit-config.yaml | 87 ------------------- prek.toml | 139 ++++++++++++++++++++++++++++++ readme.md | 13 +-- 4 files changed, 148 insertions(+), 93 deletions(-) delete mode 100644 .pre-commit-config.yaml create mode 100644 prek.toml diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index 237a496..f5eb8b4 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -34,7 +34,7 @@ jobs: uv sync - name: Code checks - run: export SKIP=no-commit-to-branch; uv run pre-commit run --all-files + run: export PREK_SKIP=no-commit-to-branch; uv run prek run --all-files - name: building addon run: uv run scons && uv run scons pot diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index 207177d..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,87 +0,0 @@ -# Copied from https://github.com/nvaccess/nvda -# https://pre-commit.ci/ -# Configuration for Continuous Integration service -ci: - # Pyright does not seem to work in pre-commit CI - skip: [pyright] - autoupdate_schedule: monthly - autoupdate_commit_msg: "Pre-commit auto-update" - autofix_commit_msg: "Pre-commit auto-fix" - submodules: true - -default_language_version: - python: python3.13 - -repos: -- repo: https://github.com/pre-commit-ci/pre-commit-ci-config - rev: v1.6.1 - hooks: - - id: check-pre-commit-ci-config - -- repo: meta - hooks: - # ensures that exclude directives apply to any file in the repository. - - id: check-useless-excludes - # ensures that the configured hooks apply to at least one file in the repository. - - id: check-hooks-apply - -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - # Prevents commits to certain branches - - id: no-commit-to-branch - args: ["--branch", "main", "--branch", "master", ] - # Checks that large files have not been added. Default cut-off for "large" files is 500kb. - - id: check-added-large-files - # Checks python syntax - - id: check-ast - # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) - - id: check-case-conflict - # Checks for artifacts from resolving merge conflicts. - - id: check-merge-conflict - # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. - - id: debug-statements - # Removes trailing whitespace. - - id: trailing-whitespace - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Ensures all files end in 1 (and only 1) newline. - - id: end-of-file-fixer - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Removes the UTF-8 BOM from files that have it. - # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding - - id: fix-byte-order-marker - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Validates TOML files. - - id: check-toml - # Validates YAML files. - - id: check-yaml - # Ensures that links to lines in files under version control point to a particular commit. - - id: check-vcs-permalinks - # Avoids using reserved Windows filenames. - - id: check-illegal-windows-names -- repo: https://github.com/asottile/add-trailing-comma - rev: v3.2.0 - hooks: - # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, - # if a trailing comma is added. - # This adds a trailing comma to args/iterable items in case it was missed. - - id: add-trailing-comma - -- repo: https://github.com/astral-sh/ruff-pre-commit - # Matches Ruff version in pyproject. - rev: v0.12.7 - hooks: - - id: ruff - name: lint with ruff - args: [ --fix ] - - id: ruff-format - name: format with ruff - -- repo: local - hooks: - - - id: pyright - name: type check with pyright - entry: uv run pyright - language: system - types: [python] diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..af9e227 --- /dev/null +++ b/prek.toml @@ -0,0 +1,139 @@ +# Configuration file for `prek`, a git hook framework written in Rust. +# See https://prek.j178.dev for more information. +#:schema https://www.schemastore.org/prek.json + +default_language_version = { python = "python3.13" } + +[[repos]] +repo = "meta" +hooks = [ + # ensures that exclude directives apply to any file in the repository. + { id = "check-useless-excludes" }, + # ensures that the configured hooks apply to at least one file in the repository. + { id = "check-hooks-apply" } +] + +# Built-in hooks reimplemented in Rust by prek. No repo clone or environment setup required. +[[repos]] +repo = "builtin" +hooks = [ + # Prevents commits to certain branches + { + id = "no-commit-to-branch", + args = [ + "--branch", + "main", + "--branch", + "master" + ] + }, + # Checks that large files have not been added. Default cut-off for "large" files is 500kb. + { id = "check-added-large-files" }, + # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) + { id = "check-case-conflict" }, + # Checks for artifacts from resolving merge conflicts. + { id = "check-merge-conflict" }, + # Removes trailing whitespace. + { + id = "trailing-whitespace", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Ensures all files end in 1 (and only 1) newline. + { + id = "end-of-file-fixer", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Removes the UTF-8 BOM from files that have it. + # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding + { + id = "fix-byte-order-marker", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Validates TOML files. + { id = "check-toml" }, + # Validates YAML files. + { id = "check-yaml" }, + # Ensures that links to lines in files under version control point to a particular commit. + { id = "check-vcs-permalinks" } +] + +# These hooks use the upstream repo rather than prek's builtin equivalents: +# - check-ast and debug-statements have no builtin implementation. +# - The builtin check-illegal-windows-names only matches files that already have illegal names, +# so check-hooks-apply reports it as unused on a clean repo; the upstream hook checks all files. +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v5.0.0" +hooks = [ + # Checks python syntax + { id = "check-ast" }, + # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. + { id = "debug-statements" }, + # Avoids using reserved Windows filenames. + { id = "check-illegal-windows-names" } +] + +[[repos]] +repo = "https://github.com/asottile/add-trailing-comma" +rev = "v3.2.0" +hooks = [ + # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, + # if a trailing comma is added. + # This adds a trailing comma to args/iterable items in case it was missed. + { id = "add-trailing-comma" } +] + +[[repos]] +# Keep in sync with the pinned Ruff version in pyproject.toml. +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.14.5" +hooks = [ + { + id = "ruff", + name = "lint with ruff", + args = ["--fix"] + }, + { + id = "ruff-format", + name = "format with ruff" + } +] + +[[repos]] +repo = "local" +hooks = [ + { + id = "pyright", + name = "type check with pyright", + entry = "uv run pyright", + language = "system", + types = ["python"] + } +] diff --git a/readme.md b/readme.md index 9cc497b..c8a4d39 100644 --- a/readme.md +++ b/readme.md @@ -45,12 +45,15 @@ In addition, this template includes configuration files for the following tools ## Automatic checks on GitHub -### Pre-commit +### prek -It's recommended to install pre-commit.ci [pre-commit](https://pre-commit.ci) on personal GitHub accounts. -Then, you can choose if pre-commit will be used in all or just in selected repos. +This template uses [prek](https://github.com/j178/prek) (a fast, drop-in alternative to pre-commit) to run linting, formatting, and type-checking hooks, configured in `prek.toml`. +`prek` is included as a development dependency, so you can run it through `uv`: -Setting up pre-commit.ci for each add-on using the add-on template will help you maintain a consistent code style in your add-ons. +* Run `uv run prek install` once to enable the git hook, so the checks run automatically on every commit. +* Run `uv run prek run --all-files` to check all files in the repository. + +The provided GitHub Actions workflow (`.github/workflows/build_addon.yml`) also runs these checks on every pull request, helping you maintain a consistent code style in your add-ons. ## Requirements @@ -91,7 +94,7 @@ sconstruct ``` and file: ``` -.pre-commit-config.yaml +prek.toml changelog.md pyproject.toml uv.lock From 7bfb0fba5002a5a1dae3afd4ccce38cf311872e3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 14:20:09 +0200 Subject: [PATCH 31/44] restore pyproject.toml from master to fix conflict --- pyproject.toml | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fa7aa9..8766194 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,27 +20,41 @@ classifiers = [ ] readme = "readme.md" license = {file = "COPYING.TXT"} -dependencies = [ - # Build add-on +# This package is not installed as a library; every dependency is tooling used to +# build, translate, or lint the add-on. They are declared as PEP 735 dependency groups. +dependencies = [] +[project.urls] +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 = [ "scons==4.10.1", "Markdown==3.10", - # Translations management +] +# Translations management +l10n = [ "nh3==0.3.2", "crowdin-api-client==1.24.1", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - # Lint +] +# Lint +lint = [ "uv==0.11.15", "ruff==0.14.5", - "pre-commit==4.2.0", - "pyright[nodejs]==1.1.411", - # Repository synchronization machinery - "tomlkit==0.13.0", + "prek==0.4.8", + "pyright[nodejs]==1.1.407", +] +dev = [ + { include-group = "build" }, + { include-group = "l10n" }, + { include-group = "lint" }, ] -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" [tool.ruff] line-length = 110 @@ -66,7 +80,6 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", - "updateAddonFromTemplate.py", ] [tool.ruff.format] @@ -105,7 +118,6 @@ exclude = [ ".venv", "site_scons", ".github/scripts", - "updateAddonFromTemplate.py", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. From 3bc1408401bd6fbdf307fc97304cd19b3e4635e1 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 14:22:52 +0200 Subject: [PATCH 32/44] Restore pyproject.toml from PR branch to fix conflict --- pyproject.toml | 36 ++++++++++++------------------------ 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8766194..9fa7aa9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,41 +20,27 @@ classifiers = [ ] readme = "readme.md" license = {file = "COPYING.TXT"} -# This package is not installed as a library; every dependency is tooling used to -# build, translate, or lint the add-on. They are declared as PEP 735 dependency groups. -dependencies = [] -[project.urls] -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 = [ +dependencies = [ + # Build add-on "scons==4.10.1", "Markdown==3.10", -] -# Translations management -l10n = [ + # Translations management "nh3==0.3.2", "crowdin-api-client==1.24.1", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", -] -# Lint -lint = [ + # Lint "uv==0.11.15", "ruff==0.14.5", - "prek==0.4.8", - "pyright[nodejs]==1.1.407", -] -dev = [ - { include-group = "build" }, - { include-group = "l10n" }, - { include-group = "lint" }, + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.411", + # Repository synchronization machinery + "tomlkit==0.13.0", ] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" [tool.ruff] line-length = 110 @@ -80,6 +66,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", + "updateAddonFromTemplate.py", ] [tool.ruff.format] @@ -118,6 +105,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "updateAddonFromTemplate.py", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. From ea2b0d3a0035a2c2ae982b6a4084dbcf5eaec22e Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 14:32:39 +0200 Subject: [PATCH 33/44] Fix a conflict in pyproject.toml caused by a recent commit made to master --- pyproject.toml | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9fa7aa9..e57f6d1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,27 +20,42 @@ classifiers = [ ] readme = "readme.md" license = {file = "COPYING.TXT"} -dependencies = [ - # Build add-on +# This package is not installed as a library; every dependency is tooling used to +# build, translate, or lint the add-on. They are declared as PEP 735 dependency groups. +dependencies = [] +[project.urls] +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 & repository synchronization machinery +build = [ "scons==4.10.1", "Markdown==3.10", - # Translations management + "tomlkit==0.13.0", +] +# Translations management +l10n = [ "nh3==0.3.2", "crowdin-api-client==1.24.1", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - # Lint +] +# Lint +lint = [ "uv==0.11.15", "ruff==0.14.5", "pre-commit==4.2.0", "pyright[nodejs]==1.1.411", - # Repository synchronization machinery - "tomlkit==0.13.0", ] -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" +dev = [ + { include-group = "build" }, + { include-group = "l10n" }, + { include-group = "lint" }, +] [tool.ruff] line-length = 110 From cc9fd265a300fe8ba0af8482b3d587289c6a67ca Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 19:06:38 +0200 Subject: [PATCH 34/44] docs: reorganize and merge add-on template integration guide - Merge structural improvements from @nvdaes and the latest PR. - Restore critical details about the cleanup of placeholder metadata (such as conditional nvaccess author removal). - Clarify Git and companion tool prerequisites, aligning Python versions. - Format all paragraphs using @nvdaes' structural guidelines (frequent line breaks after periods) for optimal readability. --- .../updatingExistingAddons.md | 364 ++++++++++-------- 1 file changed, 198 insertions(+), 166 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 67629e7..aa8964e 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -4,115 +4,154 @@ 1. Create a repository, for example on GitHub, providing README and LICENSE files. -2. Clone the repository: +2. Clone the repository to your local computer: - ```sh - cd {repoFolder} + ``` git clone https://github.com/{repoName}.git - cd {repoName} ``` -3. In the folder where your add-on repository is cloned, create an ```addon``` subfolder and store the code for your add-on. +3. Go to the folder where your repository was cloned: -4. Commit your changes: + ``` + cd {repoFolder} + ``` - ```sh +4. In this folder, create an `addon` subfolder and store the code for your add-on. + +5. Commit your initial changes: + + ``` git add . git commit -m "Initial commit" ``` -5. Add AddonTemplate as a remote: +6. Add AddonTemplate as a remote upstream: - ```sh + ``` git remote add template https://github.com/nvaccess/AddonTemplate.git ``` -6. Fetch the template: +7. Fetch the template branches: - ```sh + ``` git fetch template ``` +--- + ## Updating an Existing Add-on AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. -The recommended way to keep your add-on synchronized with the latest version of AddonTemplate is to use the companion update script included with the template. - -If you prefer to manage the update process yourself, a fully manual Git-based workflow is also available later in this document. +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. +> 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. + + ``` + 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. + +If anything goes wrong before the merge commit is created, and you haven't used the `--squash` option, you can safely cancel the operation using: + +``` +git merge --abort +``` --- ## 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 update script is included in AddonTemplate: ```updateAddonFromTemplate.py```. +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion update script is included in AddonTemplate: `updateAddonFromTemplate.py`. + +This script automatically handles the extraction of old metadata. + +It also cleans template placeholder data. +For example, it removes "nvaccess" from the authors list if it is a third-party add-on and NV Access is not actually among its maintainers. +This cleanup prevents copying the template author field onto the target add-on during the update process. 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, 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, and safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. +* **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```. -- **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. +* **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`. + +* **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 + ``` python -m pip install -U tomlkit ``` + *(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`. -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. +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`. -Before updating your repository: - -- Ensure your working tree is clean. - - ```sh - git status - ``` - -- Commit or stash any pending changes. -- It is recommended to perform the update on a dedicated branch. - -Run the update script in **Standard Mode**: - -```sh -uv run updateAddonFromTemplate.py -``` + ``` + uv run updateAddonFromTemplate.py + ``` -Alternatively, run the script in **Target Directory Mode** by specifying the path to your add-on folder: +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 updateAddonFromTemplate.py ../MyAddon -``` + ``` + uv run updateAddonFromTemplate.py ../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. +> 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: -```sh +``` uv sync uv run scons ``` If everything builds successfully, stage and commit the updated infrastructure: -```sh +``` git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` @@ -133,18 +172,23 @@ You can execute the script with various command-line arguments to customize the | `-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 +#### Customizing Exclusions with `.addonmergeignore` Rather than modifying the `updateAddonFromTemplate.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**. + +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. + +* 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: + ``` # Freeze the synchronization script version updateaddonfromtemplate.py @@ -155,66 +199,81 @@ updateaddonfromtemplate.py ##### 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). + 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 `updateAddonFromTemplate.py` script from a completely different directory or an external workspace. + 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 `updateAddonFromTemplate.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):** -``` -python updateAddonFromTemplate.py -``` + + ``` + python updateAddonFromTemplate.py + ``` * **Syntax B (Script outside the add-on repository):** -``` -python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -``` + + ``` + python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon + ``` ##### 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):** -``` -python updateAddonFromTemplate.py -td /path/to/local/AddonTemplate -``` + + ``` + python updateAddonFromTemplate.py -td /path/to/local/AddonTemplate + ``` * **Syntax B (Script outside the add-on repository):** -``` -python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate -``` + + ``` + python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate + ``` ##### 3. Simulating Changes Safely (Dry Run) + Analyzes structural layouts, evaluates configurations, reads the `.addonmergeignore` directives, and builds reports without writing anything to disk. * **Syntax A (Script inside the add-on repository):** -``` -python updateAddonFromTemplate.py --dry-run -``` + + ``` + python updateAddonFromTemplate.py --dry-run + ``` * **Syntax B (Script outside the add-on repository):** -``` -python /path/to/updateAddonFromTemplate.py --dry-run -ad /path/to/my-nvda-addon -``` + + ``` + python /path/to/updateAddonFromTemplate.py --dry-run -ad /path/to/my-nvda-addon + ``` ##### 4. Speeding Up with Backup Omission + Target a project repository while skipping the automated safety backup creation phase to speed up execution. * **Syntax A (Script inside the add-on repository):** -``` -python updateAddonFromTemplate.py --skip-backup -``` + + ``` + python updateAddonFromTemplate.py --skip-backup + ``` * **Syntax B (Script outside the add-on repository):** -``` -python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-backup -``` + + ``` + python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-backup + ``` --- @@ -222,56 +281,26 @@ python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-bac If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. -### Before you begin - -Before updating your repository: - -- Ensure your working tree is clean. - - ```sh - git status - ``` - -- Commit or stash any pending changes. - -- It is recommended to perform the update on a dedicated branch. - -If anything goes wrong before the merge commit is created, and you haven't used the ```--squash``` option, you can safely cancel the operation using: - -```sh -git merge --abort -``` - -### Adding the template repository - -If you have not already done so, add AddonTemplate as a remote: - -```sh -git remote add template https://github.com/nvaccess/AddonTemplate.git -``` - -Then fetch the latest changes: - -```sh -git fetch template -``` - ### Merging the latest template Merge the latest version of AddonTemplate: -```sh +``` 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``` option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. +* **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. @@ -279,107 +308,108 @@ During the merge, Git attempts to combine the contents of both repositories auto When Git cannot determine which version should be kept, it reports a merge conflict. A conflict does **not** mean that something went wrong. - It simply means that some files require manual review. ### Resolving the merge -### Using the restore command - -The ```restore``` command can be used to update files in your working directory, i.e. the folder where your add-on repository was cloned. +#### Using the restore command -The ```--source``` option specifies where the files should be restored from. +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` 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. -To restore the Markdown files from your repository and prevent them from being overwritten by the template, run: +To keep your `.md` files from your add-on repository, ensuring they aren't replaced with files from the template, you can use the following command: -```sh +``` git restore *.md --source=HEAD ``` -### Remove the template documentation - -The ```docs/``` directory belongs to AddonTemplate itself. +#### Remove the template documentation -It is intended for developing AddonTemplate and should not become part of your add-on repository. +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 ``` -Alternatively, if the directory already exists in your repository, you can restore your own version: +Or use the restore command: -```sh +``` git restore docs --source=HEAD ``` -### Resolve ```buildVars.py``` +#### Resolve `buildVars.py` -```buildVars.py``` usually contains merge conflicts because it includes both: +`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 updated 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. +`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 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 +--- -Once all conflicts have been resolved, verify that the add-on still builds correctly: +### Completing the merge -```sh +Once all conflicts have been resolved, check if the add-on can be built properly: + +``` uv sync uv run scons ``` If everything builds successfully, stage the modified files: -```sh +``` git add . ``` -Then create the commit: +Then create the merge commit: -```sh +``` 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 | @@ -387,13 +417,15 @@ git commit -m "chore: sync infrastructure with AddonTemplate" | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | +--- + ## Troubleshooting ### I don't understand a merge conflict Merge conflicts are expected when updating from a newer version of AddonTemplate. -Most conflicts occur in ```buildVars.py``` and ```pyproject.toml```. +Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. @@ -401,44 +433,44 @@ If you are unsure whether a change comes from your add-on or from AddonTemplate, ### I want to cancel the update -#### Automated update +#### If using the Automated update: 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 have already staged some changes, you can also discard them using: -```sh +``` git restore . --staged ``` Then restore your working tree: -```sh +``` git restore . --source=HEAD ``` -#### Manual update +#### If using the Manual update: -If you have not yet committed the merge and **did not** use the ```--squash``` option, you can cancel it with: +If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: -```sh +``` git merge --abort ``` -If you performed a squash merge, ```git merge --abort``` is no longer available because no merge state is recorded. +If you performed a squash merge, `git merge --abort` is no longer available because no merge state is recorded in Git. -In this case, restore your repository with: +In this case, restore your repository manually with: -```sh +``` git restore . --staged ``` -```sh +``` git restore . --source=HEAD ``` If you have already committed the update and want to return to the previous state, you can reset your branch: -```sh +``` git reset --hard {cleanBranch} ``` From d39c0bdba78a82f10c8b725e1d91c2cb7a52dc24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 14 Jul 2026 21:27:30 +0200 Subject: [PATCH 35/44] Improve documentation --- .../updatingExistingAddons.md | 102 ++++++++---------- 1 file changed, 47 insertions(+), 55 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index aa8964e..5964bf4 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -6,13 +6,13 @@ 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} ``` @@ -20,25 +20,11 @@ 5. Commit your initial changes: - ``` + ```sh git add . git commit -m "Initial commit" ``` -6. Add AddonTemplate as a remote upstream: - - ``` - git remote add template https://github.com/nvaccess/AddonTemplate.git - ``` - -7. Fetch the template branches: - - ``` - git fetch template - ``` - ---- - ## Updating an Existing Add-on AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. @@ -59,7 +45,7 @@ Before initiating any update workflow (automated or manual), please complete the * **Check repository status**: Ensure your working tree is clean. - ``` + ```sh git status ``` @@ -69,12 +55,6 @@ Before initiating any update workflow (automated or manual), please complete the * **Use a dedicated branch**: It is highly recommended to perform the update on a separate, dedicated branch to isolate changes. -If anything goes wrong before the merge commit is created, and you haven't used the `--squash` option, you can safely cancel the operation using: - -``` -git merge --abort -``` - --- ## Recommended Method: Automated Update Using the Companion Tool @@ -107,13 +87,13 @@ Before running the tool, ensure your system meets the following requirements: * **Git**: Git must be installed and available in your system `PATH`. -* **Dependency Management (tomlkit)**: +* 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 python -m pip install -U tomlkit ``` @@ -127,14 +107,14 @@ The script is highly flexible and supports two execution modes: 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 updateAddonFromTemplate.py ``` 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 updateAddonFromTemplate.py ../MyAddon ``` @@ -144,14 +124,15 @@ The script is highly flexible and supports two execution modes: Once the update has completed, verify that the add-on still builds correctly: -``` +```sh uv sync uv run scons ``` -If everything builds successfully, stage and commit the updated infrastructure: +If everything builds successfully, remove the `>_bak_`, stage and commit the updated infrastructure: -``` +```sh +git clean -f git add . git commit -m "chore: sync infrastructure with AddonTemplate" ``` @@ -189,7 +170,7 @@ To declare custom exceptions, create a plain text file named `.addonmergeignore` 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: -``` +```sh # Freeze the synchronization script version updateaddonfromtemplate.py # Protect your local merge settings file from being replaced @@ -217,13 +198,13 @@ Downloads the latest remote template, creates a safety backup of your repository * **Syntax A (Script inside the add-on repository):** - ``` + ```sh python updateAddonFromTemplate.py ``` * **Syntax B (Script outside the add-on repository):** - ``` + ```sh python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon ``` @@ -233,13 +214,13 @@ Useful when testing local modifications applied to the `AddonTemplate` or when w * **Syntax A (Script inside the add-on repository):** - ``` + ```sh python updateAddonFromTemplate.py -td /path/to/local/AddonTemplate ``` * **Syntax B (Script outside the add-on repository):** - ``` + ```sh python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` @@ -249,13 +230,13 @@ Analyzes structural layouts, evaluates configurations, reads the `.addonmergeign * **Syntax A (Script inside the add-on repository):** - ``` + ```sh python updateAddonFromTemplate.py --dry-run ``` * **Syntax B (Script outside the add-on repository):** - ``` + ```sh python /path/to/updateAddonFromTemplate.py --dry-run -ad /path/to/my-nvda-addon ``` @@ -265,13 +246,13 @@ Target a project repository while skipping the automated safety backup creation * **Syntax A (Script inside the add-on repository):** - ``` + ```sh python updateAddonFromTemplate.py --skip-backup ``` * **Syntax B (Script outside the add-on repository):** - ``` + ```sh python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-backup ``` @@ -281,11 +262,25 @@ Target a project repository while skipping the automated safety backup creation 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 addonTemplate https://github.com/nvaccess/addonTemplate.git +``` + +2. Fetch the addonTemplate: + +```sh +fetch addonTemplate +``` + ### Merging the latest template Merge the latest version of AddonTemplate: -``` +```sh git merge template/master --allow-unrelated-histories --squash ``` @@ -323,7 +318,7 @@ Your add-on documentation should not be replaced by the template. To keep your `.md` files from your add-on repository, ensuring they aren't replaced with files from the template, you can use the following command: -``` +```sh git restore *.md --source=HEAD ``` @@ -334,13 +329,13 @@ It is not intended to become part of your add-on repository. Remove it: -``` +```sh git rm -r docs ``` Or use the restore command: -``` +```sh git restore docs --source=HEAD ``` @@ -387,20 +382,20 @@ Review any conflicts if necessary before completing the merge. Once all conflicts have been resolved, check if the add-on can be built properly: -``` +```sh uv sync uv run scons ``` If everything builds successfully, stage the modified files: -``` +```sh git add . ``` Then create the merge commit: -``` +```sh git commit -m "chore: sync infrastructure with AddonTemplate" ``` @@ -439,13 +434,13 @@ Since the automated script creates an untracked timestamped full copy backup dir If you have already staged some changes, you can also discard them using: -``` +```sh git restore . --staged ``` Then restore your working tree: -``` +```sh git restore . --source=HEAD ``` @@ -453,7 +448,7 @@ git restore . --source=HEAD If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: -``` +```sh git merge --abort ``` @@ -461,16 +456,13 @@ If you performed a squash merge, `git merge --abort` is no longer available beca In this case, restore your repository manually with: -``` +```sh git restore . --staged -``` - -``` git restore . --source=HEAD ``` If you have already committed the update and want to return to the previous state, you can reset your branch: -``` +```sh git reset --hard {cleanBranch} ``` From c22c817582f598149887f446350645866fcfc4ea Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 14 Jul 2026 23:27:43 +0200 Subject: [PATCH 36/44] 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 9d8cba56e7d0db1151067cdccf1d98781920c784 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 15 Jul 2026 07:32:18 +0200 Subject: [PATCH 37/44] 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. --- .../updatingExistingAddons.md | 44 +++++++++---------- pyproject.toml | 4 +- ...romTemplate.py => syncAddonWithTemplate.py | 0 3 files changed, 23 insertions(+), 25 deletions(-) rename updateAddonFromTemplate.py => syncAddonWithTemplate.py (100%) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 5964bf4..e5fa364 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -59,13 +59,11 @@ Before initiating any update workflow (automated or manual), please complete the ## 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 update script is included in AddonTemplate: `updateAddonFromTemplate.py`. +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 handles the extraction of old metadata. +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. -It also cleans template placeholder data. -For example, it removes "nvaccess" from the authors list if it is a third-party add-on and NV Access is not actually among its maintainers. -This cleanup prevents copying the template author field onto the target add-on during the update process. +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: @@ -108,14 +106,14 @@ The script is highly flexible and supports two execution modes: It will automatically locate the project root by searching for `buildVars.py`. ```sh - uv run updateAddonFromTemplate.py + uv run python syncAddonWithTemplate.py ``` 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 updateAddonFromTemplate.py ../MyAddon + uv run python syncAddonWithTemplate.py ../MyAddon ``` > [!NOTE] @@ -129,7 +127,7 @@ uv sync uv run scons ``` -If everything builds successfully, remove the `>_bak_`, stage and commit the updated infrastructure: +If everything builds successfully, remove the _bak_ directory, stage and commit the updated infrastructure: ```sh git clean -f @@ -139,7 +137,7 @@ git commit -m "chore: sync infrastructure with AddonTemplate" ### Using the Update Tool via Command Line -The `updateAddonFromTemplate.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`. +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. @@ -155,7 +153,7 @@ You can execute the script with various command-line arguments to customize the #### Customizing Exclusions with `.addonmergeignore` -Rather than modifying the `updateAddonFromTemplate.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`. +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. @@ -172,7 +170,7 @@ For instance, if you wish to prevent the synchronization process from overwritin ```sh # Freeze the synchronization script version -updateaddonfromtemplate.py +syncAddonWithTemplate.py # Protect your local merge settings file from being replaced .addonmergeignore ``` @@ -186,7 +184,7 @@ updateaddonfromtemplate.py 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 `updateAddonFromTemplate.py` script from a completely different directory or an external workspace. + 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 @@ -199,13 +197,13 @@ Downloads the latest remote template, creates a safety backup of your repository * **Syntax A (Script inside the add-on repository):** ```sh - python updateAddonFromTemplate.py + uv run python syncAddonWithTemplate.py ``` * **Syntax B (Script outside the add-on repository):** ```sh - python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon ``` ##### 2. Updating from a Local Template Cache (Offline/Development) @@ -215,13 +213,13 @@ Useful when testing local modifications applied to the `AddonTemplate` or when w * **Syntax A (Script inside the add-on repository):** ```sh - python updateAddonFromTemplate.py -td /path/to/local/AddonTemplate + uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate ``` * **Syntax B (Script outside the add-on repository):** ```sh - python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` ##### 3. Simulating Changes Safely (Dry Run) @@ -231,13 +229,13 @@ Analyzes structural layouts, evaluates configurations, reads the `.addonmergeign * **Syntax A (Script inside the add-on repository):** ```sh - python updateAddonFromTemplate.py --dry-run + uv run python syncAddonWithTemplate.py --dry-run ``` * **Syntax B (Script outside the add-on repository):** ```sh - python /path/to/updateAddonFromTemplate.py --dry-run -ad /path/to/my-nvda-addon + uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon ``` ##### 4. Speeding Up with Backup Omission @@ -247,13 +245,13 @@ Target a project repository while skipping the automated safety backup creation * **Syntax A (Script inside the add-on repository):** ```sh - python updateAddonFromTemplate.py --skip-backup + uv run python syncAddonWithTemplate.py --skip-backup ``` * **Syntax B (Script outside the add-on repository):** ```sh - python /path/to/updateAddonFromTemplate.py -ad /path/to/my-nvda-addon --skip-backup + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup ``` --- @@ -267,13 +265,13 @@ If you prefer not to use the automated tool, you can manually merge the latest v 1. If you haven't done it yet, from your add-on repository, add the addonTemplate as a remote. ```sh -git remote add addonTemplate https://github.com/nvaccess/addonTemplate.git +git remote add template https://github.com/nvaccess/addonTemplate.git ``` -2. Fetch the addonTemplate: +2. Fetch the template: ```sh -fetch addonTemplate +git fetch template ``` ### Merging the latest template diff --git a/pyproject.toml b/pyproject.toml index e57f6d1..2126f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", - "updateAddonFromTemplate.py", + "syncAddonWithTemplate.py", ] [tool.ruff.format] @@ -120,7 +120,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", - "updateAddonFromTemplate.py", + "syncAddonWithTemplate.py", # 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/updateAddonFromTemplate.py b/syncAddonWithTemplate.py similarity index 100% rename from updateAddonFromTemplate.py rename to syncAddonWithTemplate.py From 073ebc0dfa40061eee6b9c7d9884dba4252e14e4 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 15 Jul 2026 09:51:34 +0200 Subject: [PATCH 38/44] 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 dc535b9bb8bd278a3663f25938cd5106fcbc793b Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 16 Jul 2026 08:24:41 +0200 Subject: [PATCH 39/44] 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. --- .../updatingExistingAddons.md | 109 +++++- syncAddonWithTemplate.py | 177 ++++++---- tests/__init__.py | 9 + tests/test_syncAddonWithTemplate.py | 314 ++++++++++++++++++ 4 files changed, 537 insertions(+), 72 deletions(-) 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 e5fa364..6bace93 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -67,6 +67,81 @@ This automation ensures a seamless transition to the new template infrastructure 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`. + +## Pre-requisites for initial setup + +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. @@ -106,14 +181,14 @@ The script is highly flexible and supports two execution modes: It will automatically locate the project root by searching for `buildVars.py`. ```sh - uv run python syncAddonWithTemplate.py + 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 ../MyAddon + uv run python syncAddonWithTemplate.py -ad ../MyAddon ``` > [!NOTE] @@ -197,7 +272,7 @@ Downloads the latest remote template, creates a safety backup of your repository * **Syntax A (Script inside the add-on repository):** ```sh - uv run python syncAddonWithTemplate.py + uv run python syncAddonWithTemplate.py -ad . ``` * **Syntax B (Script outside the add-on repository):** @@ -213,7 +288,7 @@ Useful when testing local modifications applied to the `AddonTemplate` or when w * **Syntax A (Script inside the add-on repository):** ```sh - uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate + uv run python syncAddonWithTemplate.py -ad . -td /path/to/local/AddonTemplate ``` * **Syntax B (Script outside the add-on repository):** @@ -229,7 +304,7 @@ Analyzes structural layouts, evaluates configurations, reads the `.addonmergeign * **Syntax A (Script inside the add-on repository):** ```sh - uv run python syncAddonWithTemplate.py --dry-run + uv run python syncAddonWithTemplate.py -ad . --dry-run ``` * **Syntax B (Script outside the add-on repository):** @@ -245,7 +320,7 @@ Target a project repository while skipping the automated safety backup creation * **Syntax A (Script inside the add-on repository):** ```sh - uv run python syncAddonWithTemplate.py --skip-backup + uv run python syncAddonWithTemplate.py -ad . --skip-backup ``` * **Syntax B (Script outside the add-on repository):** @@ -254,6 +329,26 @@ Target a project repository while skipping the automated safety backup creation uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup ``` +##### 5. Run without Installation (`--with` option) + +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 +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. Tests are stored in the `tests/` subdirectory and can be validated quickly using `pytest` inside the virtual environment managed by `uv`. + +To run the unit test suite on your local workspace, use the following command: + +```sh +uv run pytest +``` + --- ## Alternative Method: Manual Update Using Git Merge @@ -463,4 +558,4 @@ If you have already committed the update and want to return to the previous stat ```sh git reset --hard {cleanBranch} -``` +`````` 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.", 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..6cc85d3 --- /dev/null +++ b/tests/test_syncAddonWithTemplate.py @@ -0,0 +1,314 @@ +# 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 ast +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any +from unittest.mock import patch + +import tomlkit + +# Import the functions to test from the main module +from syncAddonWithTemplate import ( + cleanupPlaceholderAuthors, + deepMergeDicts, + extractBuildvarsMetadata, + formatAuthorList, + getBasePackageName, + mergeBuildvarsFile, + mergePyprojectToml, + parseAstDict, + parseAstKeywords, + replaceAstRange, +) + + +class TestSyncAddonWithTemplate(unittest.TestCase): + """Unit tests for checking the robust synchronization and merging helper functions.""" + + def test_parseAstDict(self) -> None: + """Test parsing of an AST Dict node containing literal values and translatable strings. + + :return: None + """ + codeExpression = "{'name': 'My Addon', 'summary': _('A great helper')}" + tree = ast.parse(codeExpression) + dictNode = tree.body[0].value + self.assertTrue(isinstance(dictNode, ast.Dict)) + + result = parseAstDict(dictNode) + expected = {"name": "My Addon", "summary": "A great helper"} + self.assertEqual(result, expected) + + def test_parseAstKeywords(self) -> None: + """Test parsing of AST keyword arguments from a call expression (e.g. AddonInfo). + + :return: None + """ + codeExpression = "AddonInfo(addon_name='Test', addon_summary=_('Test Summary'))" + tree = ast.parse(codeExpression) + callNode = tree.body[0].value + self.assertTrue(isinstance(callNode, ast.Call)) + + result = parseAstKeywords(callNode.keywords) + expected = {"addon_name": "Test", "addon_summary": "Test Summary"} + self.assertEqual(result, expected) + + def test_formatAuthorList(self) -> None: + """Test formatting of comma-separated author strings into a list of inline tables. + + :return: None + """ + rawAuthors = "John Doe , Jane Smith, Bob " + result = formatAuthorList(rawAuthors) + + self.assertEqual(len(result), 3) + self.assertEqual(result[0]["name"], "John Doe") + self.assertEqual(result[0]["email"], "john@example.com") + self.assertEqual(result[1]["name"], "Jane Smith") + self.assertEqual(result[1]["email"], "") + self.assertEqual(result[2]["name"], "Bob") + self.assertEqual(result[2]["email"], "bob@dev.org") + + def test_cleanupPlaceholderAuthors(self) -> None: + """Test removal of NV Access placeholder author entries from project tables. + + :return: None + """ + projectSection = { + "authors": [ + {"name": "Developer One", "email": "dev1@example.com"}, + {"name": "NV Access", "email": "info@nvaccess.org"}, + ], + "maintainers": [ + {"name": "nvaccess", "email": "info@nvaccess.org"}, + {"name": "Developer Two", "email": "dev2@example.com"}, + ], + } + + cleanupPlaceholderAuthors(projectSection) + + self.assertEqual(len(projectSection["authors"]), 1) + self.assertEqual(projectSection["authors"][0]["name"], "Developer One") + self.assertEqual(len(projectSection["maintainers"]), 1) + self.assertEqual(projectSection["maintainers"][0]["name"], "Developer Two") + + def test_getBasePackageName(self) -> None: + """Test extracting a dependency base name with operators, markers, and versions. + + :return: None + """ + self.assertEqual(getBasePackageName("requests>=2.28.0"), "requests") + self.assertEqual(getBasePackageName("tomlkit==0.11.6; python_version >= '3.11'"), "tomlkit") + self.assertEqual(getBasePackageName(" BeautifulSoup4<=4.12.0 "), "beautifulsoup4") + + def test_replaceAstRange(self) -> None: + """Test replacing target lines in a source text list using a mapping of line indexes. + + :return: None + """ + tplLines = [ + "line 1\n", + "line 2 to replace\n", + "line 3 to replace\n", + "line 4\n", + ] + replacements = {(1, 3): "replaced line\n"} + + replaceAstRange(tplLines, replacements) + + expected = [ + "line 1\n", + "replaced line\n", + "line 4\n", + ] + self.assertEqual(tplLines, expected) + + def test_deepMergeDicts(self) -> None: + """Test recursive merging of template settings into an existing dictionary. + + :return: None + """ + dictProj = { + "tool": { + "ruff": { + "line-length": 120, + "select": ["E", "F"], + } + }, + "project": { + "dependencies": ["requests"], + }, + } + + dictTpl = { + "tool": { + "ruff": { + "select": ["E", "F", "W"], + }, + "pyright": { + "typeCheckingMode": "basic", + }, + }, + "project": { + "dependencies": ["tomlkit"], + }, + } + + result = deepMergeDicts(dictProj, dictTpl) + + # Ensure nested dictionaries were merged, prioritizing existing or accumulating list entries + self.assertEqual(result["tool"]["ruff"]["line-length"], 120) + self.assertEqual(result["tool"]["ruff"]["select"], ["E", "F", "W"]) + self.assertEqual(result["tool"]["pyright"]["typeCheckingMode"], "basic") + self.assertEqual(result["project"]["dependencies"], ["requests", "tomlkit"]) + + def test_extractBuildvarsMetadata(self) -> None: + """Test extracting legacy buildVars.py metadata with dictionary and global variables. + + :return: None + """ + buildvarsContent = """ +addon_info = { + "addon_name": "MyAddon", + "addon_summary": _("This is my addon"), + "addon_url": "https://github.com/user/addon", + "addon_author": "John Doe " +} + +pythonSources = ["addon", "globalPlugins"] +excludedFiles = ["test.py"] +""" + with TemporaryDirectory() as tempDir: + filePath = Path(tempDir) / "buildVars.py" + with filePath.open("w", encoding="utf-8") as f: + f.write(buildvarsContent) + + metadata, globalVars = extractBuildvarsMetadata(filePath) + + self.assertEqual(metadata["addon_name"], "MyAddon") + self.assertEqual(metadata["addon_summary"], "This is my addon") + self.assertEqual(metadata["addon_url"], "https://github.com/user/addon") + self.assertEqual(metadata["addon_author"], "John Doe ") + + self.assertEqual(globalVars["pythonSources"], "['addon', 'globalPlugins']") + self.assertEqual(globalVars["excludedFiles"], "['test.py']") + + def test_mergePyprojectToml_creation(self) -> None: + """Test creating a pyproject.toml when it does not exist, utilizing metadata. + + :return: None + """ + metadata = { + "addon_name": "DynamicAddon", + "addon_summary": "Summary of addon", + "addon_url": "https://github.com/org/repo", + "addon_author": "Author One ", + } + + with TemporaryDirectory() as tempDir: + projPath = Path(tempDir) / "pyproject.toml" + tplPath = Path(tempDir) / "template_pyproject.toml" + + # Populate template with empty tool definitions + tplContent = "[project]\n[project.urls]\n" + with tplPath.open("w", encoding="utf-8") as f: + f.write(tplContent) + + status = mergePyprojectToml(projPath, tplPath, metadata, dryRun=False) + self.assertEqual(status, "created from template") + + with projPath.open("r", encoding="utf-8") as f: + createdData = tomlkit.parse(f.read()) + + self.assertEqual(createdData["project"]["name"], "DynamicAddon") + self.assertEqual(createdData["project"]["description"], "Summary of addon") + self.assertEqual(createdData["project"]["urls"]["Repository"], "https://github.com/org/repo") + self.assertEqual(createdData["project"]["maintainers"][0]["name"], "Author One") + + def test_mergePyprojectToml_intelligent_merge(self) -> None: + """Test merging pyproject.toml with preserve rules and dependency updates. + + :return: None + """ + with TemporaryDirectory() as tempDir: + projPath = Path(tempDir) / "pyproject.toml" + tplPath = Path(tempDir) / "template_pyproject.toml" + + projContent = """ +[project] +name = "MyAddon" +dependencies = ["requests"] + +[tool.ruff] +line-length = 120 +""" + tplContent = """ +[project] +name = "TemplateAddon" +dependencies = ["tomlkit"] + +[tool.ruff] +select = ["E"] +""" + with projPath.open("w", encoding="utf-8") as f: + f.write(projContent) + with tplPath.open("w", encoding="utf-8") as f: + f.write(tplContent) + + status = mergePyprojectToml(projPath, tplPath, {}, dryRun=False) + self.assertEqual(status, "merged intelligently (tomlkit)") + + with projPath.open("r", encoding="utf-8") as f: + mergedData = tomlkit.parse(f.read()) + + self.assertEqual(mergedData["project"]["name"], "MyAddon") + self.assertEqual(list(mergedData["project"]["dependencies"]), ["tomlkit", "requests"]) + self.assertEqual(mergedData["tool"]["ruff"]["line-length"], 120) + self.assertEqual(mergedData["tool"]["ruff"]["select"], ["E"]) + + def test_mergeBuildvarsFile(self) -> None: + """Test merging buildVars.py using AST replacements to write key values safely. + + :return: None + """ + metadata = { + "addon_name": "CoolAddon", + "addon_summary": "A very cool addon indeed", + } + globalVars = { + "pythonSources": "['addon', 'modules']", + } + + with TemporaryDirectory() as tempDir: + projPath = Path(tempDir) / "buildVars.py" + tplPath = Path(tempDir) / "template_buildVars.py" + + tplContent = """ +addon_info = AddonInfo( + addon_name="TemplateName", + addon_summary=_("TemplateSummary"), +) + +pythonSources: list[str] = ["addon"] +""" + with tplPath.open("w", encoding="utf-8") as f: + f.write(tplContent) + + status = mergeBuildvarsFile(projPath, tplPath, metadata, globalVars, dryRun=False) + self.assertEqual(status, "merged & structured (AST verified)") + + with projPath.open("r", encoding="utf-8") as f: + resultContent = f.read() + + self.assertIn('addon_name="CoolAddon"', resultContent) + self.assertIn('addon_summary=_(\'A very cool addon indeed\')', resultContent) + self.assertIn("pythonSources: list[str] = ['addon', 'modules']", resultContent) + + +if __name__ == "__main__": + unittest.main() + \ No newline at end of file From d467a366186a5a02ec333f952d5fe69e1d8ade80 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 21 Jul 2026 00:44:53 +0200 Subject: [PATCH 40/44] 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 --- .../updatingExistingAddons.md | 155 ++------- syncAddonWithTemplate.py | 328 ++++++++++-------- tests/__init__.py | 9 - tests/test_syncAddonWithTemplate.py | 314 ----------------- 4 files changed, 226 insertions(+), 580 deletions(-) delete mode 100644 tests/__init__.py delete mode 100644 tests/test_syncAddonWithTemplate.py diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 6bace93..f6ef139 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -6,88 +6,13 @@ 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`. - -## Pre-requisites for initial setup - -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} ``` @@ -95,7 +20,7 @@ The script automatically supports updating two types of legacy add-ons: 5. Commit your initial changes: - ```sh + ``` git add . git commit -m "Initial commit" ``` @@ -120,7 +45,7 @@ Before initiating any update workflow (automated or manual), please complete the * **Check repository status**: Ensure your working tree is clean. - ```sh + ``` git status ``` @@ -166,7 +91,7 @@ Before running the tool, ensure your system meets the following requirements: To install or update `tomlkit` globally, run the following command in your terminal: - ```sh + ``` python -m pip install -U tomlkit ``` @@ -180,14 +105,14 @@ The script is highly flexible and supports two execution modes: 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 ``` @@ -197,14 +122,14 @@ The script is highly flexible and supports two execution modes: Once the update has completed, verify that the add-on still builds correctly: -```sh +``` uv sync uv run scons ``` -If everything builds successfully, remove the _bak_ directory, stage and commit the updated infrastructure: +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" @@ -243,7 +168,7 @@ To declare custom exceptions, create a plain text file named `.addonmergeignore` 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: -```sh +``` # Freeze the synchronization script version syncAddonWithTemplate.py # Protect your local merge settings file from being replaced @@ -271,13 +196,13 @@ Downloads the latest remote template, creates a safety backup of your repository * **Syntax A (Script inside the add-on repository):** - ```sh + ``` uv run python syncAddonWithTemplate.py -ad . ``` * **Syntax B (Script outside the add-on repository):** - ```sh + ``` uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon ``` @@ -287,13 +212,13 @@ Useful when testing local modifications applied to the `AddonTemplate` or when w * **Syntax A (Script inside the add-on repository):** - ```sh + ``` uv run python syncAddonWithTemplate.py -ad . -td /path/to/local/AddonTemplate ``` * **Syntax B (Script outside the add-on repository):** - ```sh + ``` uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` @@ -303,13 +228,13 @@ Analyzes structural layouts, evaluates configurations, reads the `.addonmergeign * **Syntax A (Script inside the add-on repository):** - ```sh + ``` uv run python syncAddonWithTemplate.py -ad . --dry-run ``` * **Syntax B (Script outside the add-on repository):** - ```sh + ``` uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon ``` @@ -319,13 +244,13 @@ Target a project repository while skipping the automated safety backup creation * **Syntax A (Script inside the add-on repository):** - ```sh + ``` uv run python syncAddonWithTemplate.py -ad . --skip-backup ``` * **Syntax B (Script outside the add-on repository):** - ```sh + ``` uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup ``` @@ -333,20 +258,8 @@ Target a project repository while skipping the automated safety backup creation 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 -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. Tests are stored in the `tests/` subdirectory and can be validated quickly using `pytest` inside the virtual environment managed by `uv`. - -To run the unit test suite on your local workspace, use the following command: - -```sh -uv run pytest +uv run --with tomlkit python syncAddonWithTemplate.py -ad . ``` --- @@ -359,13 +272,13 @@ If you prefer not to use the automated tool, you can manually merge the latest v 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 ``` 2. Fetch the template: -```sh +``` git fetch template ``` @@ -373,7 +286,7 @@ git fetch template Merge the latest version of AddonTemplate: -```sh +``` git merge template/master --allow-unrelated-histories --squash ``` @@ -411,7 +324,7 @@ Your add-on documentation should not be replaced by the template. To keep your `.md` files from your add-on repository, ensuring they aren't replaced with files from the template, you can use the following command: -```sh +``` git restore *.md --source=HEAD ``` @@ -422,13 +335,13 @@ It is not intended to become part of your add-on repository. Remove it: -```sh +``` git rm -r docs ``` Or use the restore command: -```sh +``` git restore docs --source=HEAD ``` @@ -475,20 +388,20 @@ Review any conflicts if necessary before completing the merge. Once all conflicts have been resolved, check if the add-on can be built properly: -```sh +``` uv sync uv run scons ``` If everything builds successfully, stage the modified files: -```sh +``` git add . ``` Then create the merge commit: -```sh +``` git commit -m "chore: sync infrastructure with AddonTemplate" ``` @@ -527,13 +440,13 @@ Since the automated script creates an untracked timestamped full copy backup dir If you have already staged some changes, you can also discard them using: -```sh +``` git restore . --staged ``` Then restore your working tree: -```sh +``` git restore . --source=HEAD ``` @@ -541,7 +454,7 @@ git restore . --source=HEAD If you have not yet committed the merge and **did not** use the `--squash` option, you can cancel it with: -```sh +``` git merge --abort ``` @@ -549,13 +462,13 @@ If you performed a squash merge, `git merge --abort` is no longer available beca In this case, restore your repository manually with: -```sh +``` git restore . --staged git restore . --source=HEAD ``` If you have already committed the update and want to return to the previous state, you can reset your branch: -```sh +``` git reset --hard {cleanBranch} -`````` +``` 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. -""" diff --git a/tests/test_syncAddonWithTemplate.py b/tests/test_syncAddonWithTemplate.py deleted file mode 100644 index 6cc85d3..0000000 --- a/tests/test_syncAddonWithTemplate.py +++ /dev/null @@ -1,314 +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. - -import ast -import unittest -from pathlib import Path -from tempfile import TemporaryDirectory -from typing import Any -from unittest.mock import patch - -import tomlkit - -# Import the functions to test from the main module -from syncAddonWithTemplate import ( - cleanupPlaceholderAuthors, - deepMergeDicts, - extractBuildvarsMetadata, - formatAuthorList, - getBasePackageName, - mergeBuildvarsFile, - mergePyprojectToml, - parseAstDict, - parseAstKeywords, - replaceAstRange, -) - - -class TestSyncAddonWithTemplate(unittest.TestCase): - """Unit tests for checking the robust synchronization and merging helper functions.""" - - def test_parseAstDict(self) -> None: - """Test parsing of an AST Dict node containing literal values and translatable strings. - - :return: None - """ - codeExpression = "{'name': 'My Addon', 'summary': _('A great helper')}" - tree = ast.parse(codeExpression) - dictNode = tree.body[0].value - self.assertTrue(isinstance(dictNode, ast.Dict)) - - result = parseAstDict(dictNode) - expected = {"name": "My Addon", "summary": "A great helper"} - self.assertEqual(result, expected) - - def test_parseAstKeywords(self) -> None: - """Test parsing of AST keyword arguments from a call expression (e.g. AddonInfo). - - :return: None - """ - codeExpression = "AddonInfo(addon_name='Test', addon_summary=_('Test Summary'))" - tree = ast.parse(codeExpression) - callNode = tree.body[0].value - self.assertTrue(isinstance(callNode, ast.Call)) - - result = parseAstKeywords(callNode.keywords) - expected = {"addon_name": "Test", "addon_summary": "Test Summary"} - self.assertEqual(result, expected) - - def test_formatAuthorList(self) -> None: - """Test formatting of comma-separated author strings into a list of inline tables. - - :return: None - """ - rawAuthors = "John Doe , Jane Smith, Bob " - result = formatAuthorList(rawAuthors) - - self.assertEqual(len(result), 3) - self.assertEqual(result[0]["name"], "John Doe") - self.assertEqual(result[0]["email"], "john@example.com") - self.assertEqual(result[1]["name"], "Jane Smith") - self.assertEqual(result[1]["email"], "") - self.assertEqual(result[2]["name"], "Bob") - self.assertEqual(result[2]["email"], "bob@dev.org") - - def test_cleanupPlaceholderAuthors(self) -> None: - """Test removal of NV Access placeholder author entries from project tables. - - :return: None - """ - projectSection = { - "authors": [ - {"name": "Developer One", "email": "dev1@example.com"}, - {"name": "NV Access", "email": "info@nvaccess.org"}, - ], - "maintainers": [ - {"name": "nvaccess", "email": "info@nvaccess.org"}, - {"name": "Developer Two", "email": "dev2@example.com"}, - ], - } - - cleanupPlaceholderAuthors(projectSection) - - self.assertEqual(len(projectSection["authors"]), 1) - self.assertEqual(projectSection["authors"][0]["name"], "Developer One") - self.assertEqual(len(projectSection["maintainers"]), 1) - self.assertEqual(projectSection["maintainers"][0]["name"], "Developer Two") - - def test_getBasePackageName(self) -> None: - """Test extracting a dependency base name with operators, markers, and versions. - - :return: None - """ - self.assertEqual(getBasePackageName("requests>=2.28.0"), "requests") - self.assertEqual(getBasePackageName("tomlkit==0.11.6; python_version >= '3.11'"), "tomlkit") - self.assertEqual(getBasePackageName(" BeautifulSoup4<=4.12.0 "), "beautifulsoup4") - - def test_replaceAstRange(self) -> None: - """Test replacing target lines in a source text list using a mapping of line indexes. - - :return: None - """ - tplLines = [ - "line 1\n", - "line 2 to replace\n", - "line 3 to replace\n", - "line 4\n", - ] - replacements = {(1, 3): "replaced line\n"} - - replaceAstRange(tplLines, replacements) - - expected = [ - "line 1\n", - "replaced line\n", - "line 4\n", - ] - self.assertEqual(tplLines, expected) - - def test_deepMergeDicts(self) -> None: - """Test recursive merging of template settings into an existing dictionary. - - :return: None - """ - dictProj = { - "tool": { - "ruff": { - "line-length": 120, - "select": ["E", "F"], - } - }, - "project": { - "dependencies": ["requests"], - }, - } - - dictTpl = { - "tool": { - "ruff": { - "select": ["E", "F", "W"], - }, - "pyright": { - "typeCheckingMode": "basic", - }, - }, - "project": { - "dependencies": ["tomlkit"], - }, - } - - result = deepMergeDicts(dictProj, dictTpl) - - # Ensure nested dictionaries were merged, prioritizing existing or accumulating list entries - self.assertEqual(result["tool"]["ruff"]["line-length"], 120) - self.assertEqual(result["tool"]["ruff"]["select"], ["E", "F", "W"]) - self.assertEqual(result["tool"]["pyright"]["typeCheckingMode"], "basic") - self.assertEqual(result["project"]["dependencies"], ["requests", "tomlkit"]) - - def test_extractBuildvarsMetadata(self) -> None: - """Test extracting legacy buildVars.py metadata with dictionary and global variables. - - :return: None - """ - buildvarsContent = """ -addon_info = { - "addon_name": "MyAddon", - "addon_summary": _("This is my addon"), - "addon_url": "https://github.com/user/addon", - "addon_author": "John Doe " -} - -pythonSources = ["addon", "globalPlugins"] -excludedFiles = ["test.py"] -""" - with TemporaryDirectory() as tempDir: - filePath = Path(tempDir) / "buildVars.py" - with filePath.open("w", encoding="utf-8") as f: - f.write(buildvarsContent) - - metadata, globalVars = extractBuildvarsMetadata(filePath) - - self.assertEqual(metadata["addon_name"], "MyAddon") - self.assertEqual(metadata["addon_summary"], "This is my addon") - self.assertEqual(metadata["addon_url"], "https://github.com/user/addon") - self.assertEqual(metadata["addon_author"], "John Doe ") - - self.assertEqual(globalVars["pythonSources"], "['addon', 'globalPlugins']") - self.assertEqual(globalVars["excludedFiles"], "['test.py']") - - def test_mergePyprojectToml_creation(self) -> None: - """Test creating a pyproject.toml when it does not exist, utilizing metadata. - - :return: None - """ - metadata = { - "addon_name": "DynamicAddon", - "addon_summary": "Summary of addon", - "addon_url": "https://github.com/org/repo", - "addon_author": "Author One ", - } - - with TemporaryDirectory() as tempDir: - projPath = Path(tempDir) / "pyproject.toml" - tplPath = Path(tempDir) / "template_pyproject.toml" - - # Populate template with empty tool definitions - tplContent = "[project]\n[project.urls]\n" - with tplPath.open("w", encoding="utf-8") as f: - f.write(tplContent) - - status = mergePyprojectToml(projPath, tplPath, metadata, dryRun=False) - self.assertEqual(status, "created from template") - - with projPath.open("r", encoding="utf-8") as f: - createdData = tomlkit.parse(f.read()) - - self.assertEqual(createdData["project"]["name"], "DynamicAddon") - self.assertEqual(createdData["project"]["description"], "Summary of addon") - self.assertEqual(createdData["project"]["urls"]["Repository"], "https://github.com/org/repo") - self.assertEqual(createdData["project"]["maintainers"][0]["name"], "Author One") - - def test_mergePyprojectToml_intelligent_merge(self) -> None: - """Test merging pyproject.toml with preserve rules and dependency updates. - - :return: None - """ - with TemporaryDirectory() as tempDir: - projPath = Path(tempDir) / "pyproject.toml" - tplPath = Path(tempDir) / "template_pyproject.toml" - - projContent = """ -[project] -name = "MyAddon" -dependencies = ["requests"] - -[tool.ruff] -line-length = 120 -""" - tplContent = """ -[project] -name = "TemplateAddon" -dependencies = ["tomlkit"] - -[tool.ruff] -select = ["E"] -""" - with projPath.open("w", encoding="utf-8") as f: - f.write(projContent) - with tplPath.open("w", encoding="utf-8") as f: - f.write(tplContent) - - status = mergePyprojectToml(projPath, tplPath, {}, dryRun=False) - self.assertEqual(status, "merged intelligently (tomlkit)") - - with projPath.open("r", encoding="utf-8") as f: - mergedData = tomlkit.parse(f.read()) - - self.assertEqual(mergedData["project"]["name"], "MyAddon") - self.assertEqual(list(mergedData["project"]["dependencies"]), ["tomlkit", "requests"]) - self.assertEqual(mergedData["tool"]["ruff"]["line-length"], 120) - self.assertEqual(mergedData["tool"]["ruff"]["select"], ["E"]) - - def test_mergeBuildvarsFile(self) -> None: - """Test merging buildVars.py using AST replacements to write key values safely. - - :return: None - """ - metadata = { - "addon_name": "CoolAddon", - "addon_summary": "A very cool addon indeed", - } - globalVars = { - "pythonSources": "['addon', 'modules']", - } - - with TemporaryDirectory() as tempDir: - projPath = Path(tempDir) / "buildVars.py" - tplPath = Path(tempDir) / "template_buildVars.py" - - tplContent = """ -addon_info = AddonInfo( - addon_name="TemplateName", - addon_summary=_("TemplateSummary"), -) - -pythonSources: list[str] = ["addon"] -""" - with tplPath.open("w", encoding="utf-8") as f: - f.write(tplContent) - - status = mergeBuildvarsFile(projPath, tplPath, metadata, globalVars, dryRun=False) - self.assertEqual(status, "merged & structured (AST verified)") - - with projPath.open("r", encoding="utf-8") as f: - resultContent = f.read() - - self.assertIn('addon_name="CoolAddon"', resultContent) - self.assertIn('addon_summary=_(\'A very cool addon indeed\')', resultContent) - self.assertIn("pythonSources: list[str] = ['addon', 'modules']", resultContent) - - -if __name__ == "__main__": - unittest.main() - \ No newline at end of file From 8c204ff0005121d398242735801d970f7036757b Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 23 Jul 2026 19:04:17 +0200 Subject: [PATCH 41/44] fix(pyproject): prevent setuptools multi-module discovery error on uv sync --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 2126f4d..90c0db8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -229,3 +229,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 = [] From 64dd55d756410b6171ffc07a47285947f87f5b6e Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 23 Jul 2026 20:53:09 +0200 Subject: [PATCH 42/44] chore(deps): replace pre-commit with prek and align with master --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 90c0db8..ba426c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ l10n = [ lint = [ "uv==0.11.15", "ruff==0.14.5", - "pre-commit==4.2.0", + "prek==0.4.8", "pyright[nodejs]==1.1.411", ] dev = [ From fb2b06a2954a8f0572b8e67cc639048ee5603fc7 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 24 Jul 2026 00:17:44 +0200 Subject: [PATCH 43/44] fix(sync): prevent duplicating dev tooling in project dependencies Ensure legacy developer dependencies are filtered out if already present in PEP 735 dependency groups to avoid version conflicts during uv sync. --- syncAddonWithTemplate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 69ce4ee..189b5e9 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -498,6 +498,7 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: ".venv", "docs", ".ruff_cache", + "tests", } ignoreFilePath = os.path.join(addonDir, ".addonmergeignore") From 05e4b30fdbb2d96ef60afa860e31617ab8c2d6bc Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 24 Jul 2026 01:43:49 +0200 Subject: [PATCH 44/44] 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 189b5e9..f9c01da 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: