From 574bd731f4cbd21565ac75103f218895815395e3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 4 Aug 2026 12:12:33 +0200 Subject: [PATCH 01/52] feat(infra): add syncAddonWithTemplate.py script and unit test suite Introduces the syncAddonWithTemplate.py automation tool to streamline synchronizing add-on metadata and infrastructure with upstream template updates. Detailed changes: - Added `syncAddonWithTemplate.py` at repository root to handle AST-aware merging of buildVars.py and pyproject.toml configuration. - Configured `PROTECTED_ELEMENTS` in the sync script to prevent overwriting template-specific files (e.g., `.github/workflows/unitTests.yml` and `tests/`). - Added `.addonmergeignore` support for defining project-specific file exclusion rules during synchronization. - Added dependencies for the sync tool to `pyproject.toml`. - Added `tests/unit/test_syncAddonWithTemplate.py` to validate metadata parsing, AST transformations, TOML formatting, and execution ordering. - Updated `pyproject.toml` to exclude `syncAddonWithTemplate.py` alongside the `tests/` directory from ruff and pyright checks. - Updated `docs/managementFromGit/updatingExistingAddons.md` with full usage instructions, CLI flags, and execution modes for the sync script. - Updated `docs/unitTesting.md` with guidelines for running unit tests locally using unittest and uv. --- .addonmergeignore | 0 .../updatingExistingAddons.md | 375 ++++++-- docs/unitTesting.md | 44 +- pyproject.toml | 10 +- syncAddonWithTemplate.py | 820 ++++++++++++++++++ tests/__init__.py | 6 +- tests/unit/__init__.py | 6 +- tests/unit/template/sanity.py | 24 + tests/unit/test_syncAddonWithTemplate.py | 277 ++++++ uv.lock | 23 +- 10 files changed, 1497 insertions(+), 88 deletions(-) create mode 100644 .addonmergeignore create mode 100644 syncAddonWithTemplate.py create mode 100644 tests/unit/template/sanity.py create mode 100644 tests/unit/test_syncAddonWithTemplate.py diff --git a/.addonmergeignore b/.addonmergeignore new file mode 100644 index 0000000..e69de29 diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4895903..1a72804 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,87 +1,288 @@ # Integrating the add-on template in your add-on -## Pre-requisites +## Pre-requisites for initial setup -1. Create a repository, for example on GitHub, providing readme and license files. -1. Clone the repository: +1. Create a repository, for example on GitHub, providing README and LICENSE files. + +2. Clone the repository to your local computer: + + ```sh + git clone https://github.com/{repoName}.git + ``` + +3. Go to the folder where your repository was cloned: + + ```sh + cd {repoFolder} + ``` + +4. In this folder, create an `addon` subfolder and store the code for your add-on. + +5. Commit your initial changes: + + ```sh + git add . + git commit -m "Initial commit" + ``` + +## Updating an Existing Add-on + +AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. + +You can merge the latest template changes into your repository instead of manually copying updated files. +This document explains both the recommended automated update procedure and the manual Git-based workflow. + +> [!NOTE] +> Updating from AddonTemplate only affects your project's infrastructure (build scripts, GitHub workflows, configuration files, etc.). +> It does **not** modify your add-on's source code. + +--- + +## Before you begin + +Before initiating any update workflow (automated or manual), please complete these safety checks: + +* **Check repository status**: + Ensure your working tree is clean. ```sh - git clone https://github.com/{repoName}.git + git status ``` -1. In the folder where your add-on repository is cloned, create an `addon` submolder and store the code for your add-on. +* **Commit or stash**: + Save or stash any pending local modifications. + +* **Use a dedicated branch**: + It is highly recommended to perform the update on a separate, dedicated branch to isolate changes. + +--- + +## Recommended Method: Automated Update Using the Companion Tool + +To streamline the synchronization process and avoid dealing with syntax errors or manual merge conflicts in infrastructure files, a companion utility script is included in AddonTemplate: `syncAddonWithTemplate.py`. + +This script automatically extracts your legacy project settings (such as the add-on name, summary, authors, and repository URL from `buildVars.py`) and merges them cleanly into the newly generated `pyproject.toml` file, while safely preserving empty values if certain metadata is not set. + +This automation ensures a seamless transition to the new template infrastructure without losing your original configuration. + +The script automatically supports updating two types of legacy add-ons: + +* **Legacy Structure (Dictionary-based without pyproject.toml):** + For older add-ons where `addon_info` was defined as a standard dictionary, the tool automatically migrates the metadata to the modern `AddonInfo` object structure. + It generates a new, fully populated `pyproject.toml` file matching the latest template standards, and synchronizes all infrastructure files. + +* **Modern Structure (AddonInfo-based):** + For newer add-ons that already use the `AddonInfo` object but need upstream template updates, the tool checks for any missing metadata keys in `buildVars.py` to insert them. + It safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. + +### Prerequisites + +Before running the tool, ensure your system meets the following requirements: + +* **Python**: + Version **3.13** or newer must be installed (matching the template's required Python version). + +* **Git**: + Git must be installed and available in your system `PATH`. + +* For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: + Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be installed in your global Python environment before execution. + This is necessary because legacy add-on repositories do not include this dependency yet, and it is not yet present by default in the template's stable `master` branch. + + To install or update `tomlkit` globally, run the following command in your terminal: + + ```sh + 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`. + + ```sh + uv run python syncAddonWithTemplate.py -ad . + ``` + +2. **Target Directory Mode (With argument):** + Run the script from any working directory by supplying the optional `addonDir` path (relative or absolute) pointing to the add-on repository you wish to update. + + ```sh + uv run python syncAddonWithTemplate.py -ad ../MyAddon + ``` + +> [!NOTE] +> Before applying any modifications, the script creates an untracked backup directory located next to the add-on folder named `_bak_`. +> This directory contains a copy of the entire project before the update, allowing you to restore the previous state manually if necessary. + +Once the update has completed, verify that the add-on still builds correctly: + +```sh +uv sync +uv run scons +``` + +If everything builds successfully, remove the `_bak_` directory, stage and commit the updated infrastructure: + +```sh +git clean -f +git add . +git commit -m "chore: sync infrastructure with AddonTemplate" +``` + +### Using the Update Tool via Command Line + +The `syncAddonWithTemplate.py` script provides a non-destructive industrial update engine to align your local add-on repository layout with the latest structure of the official NVDA `AddonTemplate`. + +You can execute the script with various command-line arguments to customize the update workflow. -1. Go to the folder where your repository was cloned: +#### Available Options + +| Short Flag | Long Argument | Description | Default Value | +| :--- | :--- | :--- | :--- | +| `-ad` | `--addon-dir` | Path to the root directory of the local add-on you want to update. If not specified, the script automatically walks up from your current directory to find `buildVars.py`. | Current working directory | +| `-td` | `--template-dir` | Path to a local clone/directory of the NVDA `AddonTemplate`. When provided, the tool skips fetching the template via Git and synchronizes directly using this local reference. | None (clones from GitHub) | +| `-dr` | `--dry-run` | Simulates the execution. It analyzes structure, logs planned changes, and builds reports without writing or modifying any file on disk. | Disabled | +| `-s` | `--skip-backup` | Disables the automatic creation of a timestamped backup directory (e.g., `addonName_bak_YYYYMMDD_HHMMSS`) before processing updates. | Disabled (Backup is created) | +| `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | + +#### Customizing Exclusions with `.addonmergeignore` + +Rather than modifying the `syncAddonWithTemplate.py` core source code or changing its internal `PROTECTED_ELEMENTS` array, the update tool includes a robust file-exclusion system driven by a local file named `.addonmergeignore`. + +This architectural design allows developers to cleanly decouple their project-specific freeze preferences from the update engine machinery. + +##### How to Use `.addonmergeignore` + +To declare custom exceptions, create a plain text file named `.addonmergeignore` and place it directly **at the root of your target add-on repository**. + +* Inside this file, list the names of the files or folders you want the tool to skip during synchronization. + +* You can write one pattern per line. + Empty lines and lines starting with `#` are automatically treated as comments and ignored. + +For instance, if you wish to prevent the synchronization process from overwriting your custom execution scripts or your exclusion mapping file itself, simply add them to the file: + +```text +# Freeze the synchronization script version +syncAddonWithTemplate.py +# Protect your local merge settings file from being replaced +.addonmergeignore +``` + +##### Crucial Requirements & Design Constraints + +1. **Case-Insensitivity:** + The update tool evaluates exclusions using a standardized, case-insensitive matching algorithm. + This ensures maximum cross-platform reliability (especially between Windows and Unix-like environments). + Since the script automatically normalizes all inputs to lowercase during execution, **you can write your rules using any casing you prefer** (e.g., `UpdateAddonFromTemplate.py` or `updateaddonfromtemplate.py` will both work perfectly). + +2. **File Location Requirement:** + The update engine always loads custom exclusions from the target add-on's root folder being updated. + Therefore, **the `.addonmergeignore` file must always reside inside the destination add-on directory**, even if you are executing the `syncAddonWithTemplate.py` script from a completely different directory or an external workspace. + +#### Usage Examples + +Depending on your workflow, the script can be executed either directly from within your add-on repository or from an external directory. + +##### 1. Standard Automatic Update + +Downloads the latest remote template, creates a safety backup of your repository, and non-destructively synchronizes the machinery files. + +* **Syntax A (Script inside the add-on repository):** ```sh - cd {repoFolder} + uv run python syncAddonWithTemplate.py -ad . ``` -1. Commit your changes: +* **Syntax B (Script outside the add-on repository):** ```sh - git add . - git commit -m "Initial commit" + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon ``` -1. Add the template as a remote: +##### 2. Updating from a Local Template Cache (Offline/Development) + +Useful when testing local modifications applied to the `AddonTemplate` or when working without an active internet connection. + +* **Syntax A (Script inside the add-on repository):** ```sh - git remote add template https://github.com/nvaccess/addonTemplate.git + uv run python syncAddonWithTemplate.py -ad . -td /path/to/local/AddonTemplate ``` -1. Fetch the add-on template: +* **Syntax B (Script outside the add-on repository):** ```sh - git fetch template + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` -## Updating an Existing Add-on +##### 3. Simulating Changes Safely (Dry Run) -As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workflows, and build system updates. +Analyzes structural layouts, evaluates configurations, reads the `.addonmergeignore` directives, and builds reports without writing anything to disk. -You can merge the latest template changes into your repository instead of manually copying updated files. +* **Syntax A (Script inside the add-on repository):** -This document explains the recommended update procedure. + ```sh + uv run python syncAddonWithTemplate.py -ad . --dry-run + ``` -> [!NOTE] -> Updating from AddonTemplate only affects your project's infrastructure (build scripts, GitHub workflows, configuration files, etc.). It does **not** modify your add-on's source code. +* **Syntax B (Script outside the add-on repository):** -## Before you begin + ```sh + uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon + ``` + +##### 4. Speeding Up with Backup Omission -Before updating your repository: +Target a project repository while skipping the automated safety backup creation phase to speed up execution. -- Ensure your working tree is clean. +* **Syntax A (Script inside the add-on repository):** ```sh - git status + uv run python syncAddonWithTemplate.py -ad . --skip-backup ``` -- Commit or stash any pending changes. +* **Syntax B (Script outside the add-on repository):** + + ```sh + uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup + ``` -- It is recommended to perform the update on a dedicated branch. +##### 5. Run without Installation (`--with` option) -If anything goes wrong before the merge commit is created, if you haven't passed the `--squash- flag, you can safely cancel the operation using: +If you wish to execute the synchronization script directly without installing its mandatory dependencies (like `tomlkit`) into your current environment beforehand, you can request `uv` to fetch and expose the packages temporarily during the command lifetime by using the `--with` flag: ```sh -git merge --abort +uv run --with tomlkit python syncAddonWithTemplate.py -ad . ``` -## Adding the template repository +--- + +## Alternative Method: Manual Update Using Git Merge + +If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. + +### Fetching the add-on template repository -If you have not already done so, add AddonTemplate as a remote: +1. If you haven't done it yet, from your add-on repository, add the addonTemplate as a remote. ```sh -git remote add template https://github.com/nvaccess/AddonTemplate.git +git remote add template https://github.com/nvaccess/addonTemplate.git ``` -Then fetch the latest changes: +2. Fetch the template: ```sh git fetch template ``` -## Merging the latest template +### Merging the latest template Merge the latest version of AddonTemplate: @@ -89,14 +290,18 @@ Merge the latest version of AddonTemplate: git merge template/master --allow-unrelated-histories --squash ``` -The `--allow-unrelated-histories` option is required because your add-on repository and AddonTemplate do not share a common Git history. +* **Why `--allow-unrelated-histories`?** + This option is required because your add-on repository and AddonTemplate do not share a common Git history. -The `--squash` flags will add changes from the template as a unique commit, instead of several ones, what may be useful to keep a cleaner history on your repository. +* **Why `--squash`?** + This option stages all changes from the template as a single uncommitted change, helping keep your repository history cleaner. + It compiles the template updates into a unique commit, which is useful to keep a cleaner history on your repository. At this stage, Git may report merge conflicts. - This is completely normal. +--- + ## Understanding merge conflicts During the merge, Git attempts to combine the contents of both repositories automatically. @@ -106,14 +311,14 @@ When Git cannot determine which version should be kept, it reports a merge confl A conflict does **not** mean that something went wrong. It simply means that some files require manual review. -## Resolving the merge +### Resolving the merge -### Using the restore command +#### Using the restore command The `restore` command can be used to update files on your working directory, i.e., the folder where your add-on repository was cloned. -The `--source` flag is used to determine where files to be restored can be found. +The `--source` option is used to determine where files to be restored can be found. -### Keep your add-on documentation +#### Keep your add-on documentation Your add-on documentation should not be replaced by the template. @@ -123,15 +328,14 @@ To keep your `.md` files from your add-on repository, ensuring they aren't repla git restore *.md --source=HEAD ``` -### Remove the template documentation +#### Remove the template documentation The `docs/` directory belongs to AddonTemplate itself. - It is not intended to become part of your add-on repository. Remove it: -``` +```sh git rm -r docs ``` @@ -141,54 +345,55 @@ Or use the restore command: git restore docs --source=HEAD ``` -### Resolve buildVars.py +#### Resolve `buildVars.py` `buildVars.py` usually contains merge conflicts because it includes both: -- information specific to your add-on; -- variables introduced by newer versions of AddonTemplate. +* information specific to your add-on; +* variables introduced by newer versions of AddonTemplate. Review the file carefully. In general: -- keep your add-on metadata; -- preserve your version number; -- keep your custom settings; -- add any new variables introduced by the template. +* keep your add-on metadata; +* preserve your version number; +* keep your custom settings; +* add any new variables introduced by the template. -### Resolve pyproject.toml +#### Resolve `pyproject.toml` `pyproject.toml` is another file that commonly requires manual review. Keep your project-specific configuration while incorporating any new settings required by the updated template. -### Other files +#### Other files -For most remaining files, the version provided by AddonTemplate is generally the correct one. +For most remaining infrastructure files, the version provided by AddonTemplate is generally the correct one. Typical examples include: -- `.github/` -- `.gitignore` -- `manifest.ini.tpl` -- `manifest-translated.ini.tpl` -- `site_scons/` -- `sconstruct` +* `.github/` +* `.gitignore` +* `manifest.ini.tpl` +* `manifest-translated.ini.tpl` +* `site_scons/` +* `sconstruct` Review any conflicts if necessary before completing the merge. -## Completing the merge +--- + +### Completing the merge Once all conflicts have been resolved, check if the add-on can be built properly: ```sh -uv sync # Update dependencies -uv run scons # Build the add-on +uv sync +uv run scons ``` - -If all is right, stage the modified files: +If everything builds successfully, stage the modified files: ```sh git add . @@ -197,13 +402,15 @@ git add . Then create the merge commit: ```sh -git commit +git commit -m "chore: sync infrastructure with AddonTemplate" ``` -## Summary +--- + +## Summary of File Actions | File or directory | Recommended action | -|-------------------|--------------------| +| :--- | :--- | | `README.md` | Keep the add-on version | | `CHANGELOG.md` | Keep the add-on version | | `docs/` | Remove | @@ -211,6 +418,8 @@ git commit | `pyproject.toml` | Merge manually | | Other template files | Usually accept the template version | +--- + ## Troubleshooting ### I don't understand a merge conflict @@ -221,26 +430,44 @@ Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. +If you are unsure whether a change comes from your add-on or from AddonTemplate, compare the conflicting section with the latest version of AddonTemplate before resolving it. + ### I want to cancel the update -If you have not yet committed the merge, and you haven't passed the `--squash` flag to `git merge`, you can restore your repository to its previous state: +#### If using the Automated update: + +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 merge --abort +git restore . --staged +``` + +Then restore your working tree: + +```sh +git restore . --source=HEAD ``` -If you passed the `--squash` flag, `git merge --abort` won't work. -In this case, you can use the restore command: +#### 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: ```sh -git restore . --staged # Discard changes added to the staging area (after using `git add .`) +git merge --abort ``` +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 manually with: + ```sh -git restore . --source=HEAD # Restores the working directory to the last commit made in your add-on repository +git restore . --staged +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} diff --git a/docs/unitTesting.md b/docs/unitTesting.md index fece67f..d6af998 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -2,16 +2,50 @@ This template provides a built-in unit testing structure powered by Python's standard `unittest` framework. +Ensuring your add-on and tooling behavior remains consistent during development and following template updates is done through automated unit testing. + ## Running Tests Locally -To run the unit test suite locally using `uv`: +For unit tests to execute successfully, target modules (such as `syncAddonWithTemplate.py`) must be located at the root of the repository as sibling files to the `tests/` directory (at the same hierarchical level). This ensures Python's module discovery properly imports scripts when `unittest` runs from the project root. + +### Run the Full Test Suite + +To run the entire unit test suite with automatic test discovery and detailed output for every executed test case: ``` bash uv run python -m unittest discover -s tests -v ``` -Or execute tests for a specific file: +Here is what each part of the command does: +* `uv run`: Executes the command within the virtual environment managed by `uv`. +* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. +* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. +* `-v`: Enables verbose output, displaying the status and description of each test method individually. -``` bash -uv run python -m unittest -v tests/unit/template/test_sanity.py -``` +### Run Specific Test Suites + +You can run individual test modules during development by specifying their path: + +* **Sanity / Template Tests:** + ``` bash + uv run python -m unittest -v tests/unit/template/test_sanity.py + ``` + +* **Add-on Synchronization Tool Tests:** + ``` bash + uv run python -m unittest -v tests/unit/update/test_syncAddonWithTemplate.py + ``` + +--- + +## Synchronization Tool Test Suite Overview (`test_syncAddonWithTemplate.py`) + +The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST-based config merges, file parsing, and formatting behave predictably across project updates: + +* **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. +* **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. +* **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). +* **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. +* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs. +* **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. +* **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. diff --git a/pyproject.toml b/pyproject.toml index b0d1008..05f3347 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,11 @@ Repository = "https://github.com/nvaccess/addonTemplate" # PEP 735 dependency groups. `uv sync` installs the `dev` group by default, which # pulls in every tool needed to build, translate, and lint the add-on. [dependency-groups] -# Build add-on +# Build add-on & repository synchronization machinery build = [ "scons==4.10.1", "Markdown==3.10", + "tomlkit==0.15.0", ] # Translations management l10n = [ @@ -48,7 +49,7 @@ lint = [ "uv==0.11.15", "ruff==0.14.5", "prek==0.4.8", - "pyright[nodejs]==1.1.407", + "pyright[nodejs]==1.1.411", ] dev = [ { include-group = "build" }, @@ -80,6 +81,7 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", + "syncAddonWithTemplate.py", "tests", ] @@ -119,6 +121,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "syncAddonWithTemplate.py", "tests", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, @@ -228,3 +231,6 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. + +[tool.setuptools] +py-modules = [] diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py new file mode 100644 index 0000000..77aba0c --- /dev/null +++ b/syncAddonWithTemplate.py @@ -0,0 +1,820 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from collections.abc import MutableMapping, MutableSequence +import argparse +import ast +import logging +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import tomlkit + +logger: logging.Logger = logging.getLogger("syncAddon") + + +def buildArgParser() -> argparse.ArgumentParser: + """Build and configure the command-line argument parser. + + :return: Configured ArgumentParser object. + """ + parser: argparse.ArgumentParser = 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]: + """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] = {} + keyNode: ast.expr | None + valNode: ast.expr + for keyNode, valNode in zip(node.keys, node.values): + if keyNode is None: + continue + key: Any = getattr(keyNode, "value", None) + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val: Any = 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] = {} + keyword: ast.keyword + for keyword in keywords: + key: str | None = keyword.arg + valNode: ast.expr = keyword.value + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + val: Any = getattr(valNode, "value", None) + if key is not None: + extracted[key] = val + 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. + """ + child: ast.AST + 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. + + Omits empty 'email' fields to prevent validation errors during 'uv run scons' + and PEP 621 metadata compliance checks. + + :param rawAuthors: The raw authors string (e.g., "Author Name , Another"). + :return: A tomlkit.items.Array object containing inline tables. + """ + authorsList: tomlkit.items.Array = tomlkit.array() + authorsList.multiline(True) + + parts: list[str] = [p.strip() for p in rawAuthors.split(",") if p.strip()] + + part: str + for part in parts: + regexMatch: re.Match[str] | None = re.match(r"^(.*?)\s*<(.*?)>$", part) + if regexMatch: + name: str = regexMatch.group(1).strip() + email: str = regexMatch.group(2).strip() + authorTable: tomlkit.items.InlineTable = tomlkit.inline_table() + authorData: dict[str, str] = {"name": name} + # Omit empty email to prevent validation failures in tools like uv/scons + if email: + authorData["email"] = email + authorTable.update(authorData) + authorsList.append(authorTable) + elif part: + authorTable = tomlkit.inline_table() + # Omit email key entirely if no email address was provided + authorTable.update({"name": part}) + authorsList.append(authorTable) + + 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: list[str] = tomlText.splitlines(keepends=True) + fixedLines: list[str] = [] + inTargetArray: bool = False + + line: str + for line in lines: + stripped: str = 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.TOMLDocument = tomlkit.parse(f.read()) + + if "project" not in doc: + doc["project"] = tomlkit.table() + + projectSection: Any = 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 = 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. + + :param projectSection: The project table/dictionary within the TOML structure. + :return: None + """ + field: str + for field in ["authors", "maintainers"]: + if field in projectSection and isinstance(projectSection[field], (list, MutableSequence)): + tomlList: Any = projectSection[field] + i: int + for i in range(len(tomlList) - 1, -1, -1): + item: Any = tomlList[i] + name: Any = 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(dependencyString: str) -> str: + """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). + + :param dependencyString: The raw dependency string. + :return: The normalized base package name in lowercase. + """ + regexMatch: re.Match[str] | None = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", dependencyString.strip()) + return regexMatch.group(0).lower() if regexMatch else dependencyString.strip().lower() + + +def replaceAstRange(templateLines: list[str], replacements: dict[tuple[int, int], str]) -> None: + """Apply AST line replacements on a line-by-line list in reverse order. + + :param templateLines: 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: list[tuple[int, int]] = sorted(replacements.keys(), key=lambda x: x[0], reverse=True) + start: int + end: int + for start, end in sortedRanges: + templateLines[start:end] = [replacements[(start, end)]] + + +def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: + """Intelligently merge two dependency lists by updating package versions based on base names. + + Preserves custom user dependencies while replacing existing template tools with their newer versions. + + :param projList: The existing project's dependency list. + :param tplList: The template's dependency list. + :return: A merged list with updated versions and preserved custom items. + """ + projIndexByBase: dict[str, int] = {} + idx: int + item: Any + for idx, item in enumerate(projList): + if isinstance(item, str): + base: str = getBasePackageName(item) + projIndexByBase[base] = idx + + merged: list[Any] = list(projList) + + tplItem: Any + for tplItem in tplList: + if isinstance(tplItem, str): + tplBase: str = getBasePackageName(tplItem) + if tplBase in projIndexByBase: + targetIdx: int = projIndexByBase[tplBase] + merged[targetIdx] = tplItem + else: + merged.append(tplItem) + else: + if tplItem not in merged: + merged.append(tplItem) + + return merged + + +def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: + """Recursively merges dictTpl into dictProj. + + :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. + """ + key: str + value: Any + for key, value in dictTpl.items(): + if key in dictProj: + projVal: Any = dictProj[key] + if isinstance(projVal, MutableMapping) and isinstance(value, MutableMapping): + deepMergeDicts(projVal, value) + elif isinstance(projVal, MutableSequence) and isinstance(value, MutableSequence): + dictProj[key] = mergeDependencyLists(list(projVal), list(value)) + else: + pass + else: + dictProj[key] = value + return dictProj + + +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 mapping var_name -> (ast_node, unparsed_expr). + """ + filePathObj: Path = Path(filePath) + if not filePathObj.exists(): + return {}, {} + + with filePathObj.open("r", encoding="utf-8") as f: + try: + tree: ast.AST = ast.parse(f.read()) + except SyntaxError as syntaxErr: + logger.error("Syntax error while reading %s: %s", filePathObj, syntaxErr) + return {}, {} + + metadata: dict[str, Any] = {} + globalVars: dict[str, tuple[ast.AST, str]] = {} + topLevelVars: set[str] = { + "pythonSources", + "excludedFiles", + "baseLanguage", + "markdownExtensions", + "brailleTables", + "symbolDictionaries", + "speechDictionaries", + } + + node: ast.AST + for node in ast.walk(tree): + if isinstance(node, ast.Assign) and len(node.targets) == 1: + target: ast.expr = node.targets[0] + if not isinstance(target, ast.Name): + continue + varName: str = target.id + + if varName == "addon_info": + if isinstance(node.value, ast.Dict): + metadata.update(parseAstDict(node.value)) + 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] = (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] = (node.value, ast.unparse(node.value)) + + return metadata, globalVars + + +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. + """ + templatePathObj: Path = Path(tplPath) + projectPathObj: Path = Path(projPath) + + if not templatePathObj.exists(): + return "skipped (no template found)" + + if not projectPathObj.exists(): + try: + projData: tomlkit.TOMLDocument = createPyprojectFromTemplate(templatePathObj, metadata) + if not dryRun: + tomlOutput: str = fixTomlIndentation(tomlkit.dumps(projData)) + projectPathObj.parent.mkdir(parents=True, exist_ok=True) + with projectPathObj.open("w", encoding="utf-8") as f: + f.write(tomlOutput) + logger.info("Created missing pyproject.toml at %s", projectPathObj) + return "created from template" + except Exception as exceptionObj: + logger.error("Failed to create pyproject.toml: %s", exceptionObj) + return f"failed to create from template ({str(exceptionObj)})" + + try: + with projectPathObj.open("r", encoding="utf-8") as f: + projData = tomlkit.parse(f.read()) + with templatePathObj.open("r", encoding="utf-8") as f: + tplData: tomlkit.TOMLDocument = tomlkit.parse(f.read()) + + wasOriginallyNvaccess: bool = False + if "project" in projData: + field: str + for field in ["authors", "maintainers"]: + if field in projData["project"] and isinstance(projData["project"][field], (list, MutableSequence)): + item: Any + for item in projData["project"][field]: + name: Any = 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"]: + wasOriginallyNvaccess = True + break + + projDeps: list[Any] = [] + if "project" in projData and "dependencies" in projData["project"]: + projDeps = list(projData["project"]["dependencies"]) + del projData["project"]["dependencies"] + + mergedData: dict[str, Any] = deepMergeDicts(cast(dict[str, Any], projData), cast(dict[str, Any], tplData)) + + if "project" in mergedData: + projectSection: dict[str, Any] = mergedData["project"] + + if not wasOriginallyNvaccess: + cleanupPlaceholderAuthors(projectSection) + + if isinstance(projectSection.get("dependencies"), (list, MutableSequence)): + tplDeps: list[Any] = projectSection["dependencies"] + tplBases: set[str] = {getBasePackageName(d) for d in tplDeps} + + groupBases: set[str] = set() + if "dependency-groups" in mergedData and isinstance(mergedData["dependency-groups"], MutableMapping): + grp: Any + for grp in mergedData["dependency-groups"].values(): + if isinstance(grp, (list, MutableSequence)): + grpItem: Any + for grpItem in grp: + if isinstance(grpItem, str): + groupBases.add(getBasePackageName(grpItem)) + + legacyToolingBases: set[str] = { + "pre-commit", + "scons", + "markdown", + "nh3", + "crowdin-api-client", + "lxml", + "mdx_truly_sane_lists", + "markdown-link-attr-modifier", + "mdx-gh-links", + "uv", + "ruff", + "prek", + "pyright", + } + + dep: Any + for dep in projDeps: + base: str = getBasePackageName(dep) + isInTemplate: bool = base in tplBases or base in groupBases + isDroppedTooling: bool = base in legacyToolingBases and not isInTemplate + + if not isInTemplate and not isDroppedTooling: + tplDeps.append(dep) + + if not dryRun: + tomlOutput = fixTomlIndentation(tomlkit.dumps(cast(tomlkit.TOMLDocument, mergedData))) + with projectPathObj.open("w", encoding="utf-8") as f: + f.write(tomlOutput) + return "merged intelligently (tomlkit)" + except Exception as exceptionObj: + logger.error("Failed to merge pyproject.toml: %s", exceptionObj) + return f"failed to merge ({str(exceptionObj)})" + + +def mergeBuildvarsFile( + projPath: str | Path, + tplPath: str | Path, + metadata: 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. + + :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 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. + """ + templatePathObj: Path = Path(tplPath) + projectPathObj: Path = Path(projPath) + + if not templatePathObj.exists(): + return "failed (no template found)" + + with templatePathObj.open("r", encoding="utf-8") as f: + tplContent: str = f.read() + + try: + tree: ast.AST = ast.parse(tplContent) + except SyntaxError as syntaxErr: + return f"failed (template syntax error: {syntaxErr})" + + tplLines: list[str] = tplContent.splitlines(keepends=True) + replacements: dict[tuple[int, int], str] = {} + requiresOsImport: bool = False + + node: ast.AST + for node in ast.walk(tree): + if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "AddonInfo": + kw: ast.keyword + for kw in node.keywords: + if kw.arg in metadata: + key: str = kw.arg + val: Any = metadata[key] + formattedVal: str + if val is None: + formattedVal = "None" + elif isinstance(val, str): + isTranslatable: bool = 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: + lineContent: str = tplLines[kw.lineno - 1] + indent: str = 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: + target: ast.expr = node.targets[0] + if isinstance(target, ast.Name) and target.id in globalVars: + key = target.id + valNode: ast.AST + valExpression: str + 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())] + replacements[(node.lineno - 1, node.end_lineno)] = ( + 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 + 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: str = ast.unparse(node.annotation) + replacements[(node.lineno - 1, node.end_lineno)] = ( + f"{indent}{key}: {typeStr} = {valExpression}\n" + ) + + replaceAstRange(tplLines, replacements) + + if requiresOsImport: + hasOsImport: bool = any("import os" in line for line in tplLines[:15]) + if not hasOsImport: + tplLines.insert(0, "import os\n") + + if not dryRun: + with projectPathObj.open("w", encoding="utf-8") as f: + f.writelines(tplLines) + 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. + :return: None + """ + logger.info("Synchronizing template machinery files...") + + protectedElements: set[str] = { + "readme.md", + "changelog.md", + "addontemplate.egg-info", + ".github/workflows/unittests.yml", + "addon", + ".git", + "__pycache__", + ".venv", + "docs", + ".ruff_cache", + "tests", + } + + ignoreFilePath: str = os.path.join(addonDir, ".addonmergeignore") + if os.path.exists(ignoreFilePath): + logger.info("Reading local custom exclusions from .addonmergeignore...") + try: + with open(ignoreFilePath, "r", encoding="utf-8") as f: + line: str + for line in f: + cleanLine: str = line.strip().replace("\\", "/").lower() + if cleanLine and not cleanLine.startswith("#"): + protectedElements.add(cleanLine) + except Exception as exceptionObj: + logger.warning("Failed to parse .addonmergeignore (%s)", exceptionObj) + + syncReport: list[str] = [] + + def addReportEntry(entry: str) -> None: + """Add an entry to syncReport ensuring no duplicates exist. + + :param entry: The status report line to record. + :return: None + """ + if entry not in syncReport: + syncReport.append(entry) + + def inspectAndCopyDirectory(srcDir: str, dstDir: str) -> None: + """Inspect directory recursively for protected elements and copy non-protected files. + + :param srcDir: Path to the source directory to inspect. + :param dstDir: Path to the destination target directory. + :return: None + """ + root: str + dirs: list[str] + files: list[str] + for root, dirs, files in os.walk(srcDir): + relDir: str = os.path.relpath(root, tempDir) + + dirsToCopy: list[str] = [] + dirName: str + for dirName in dirs: + relPath: str = dirName if relDir == "." else os.path.join(relDir, dirName) + relPathNormalized: str = relPath.replace("\\", "/").lower() + if relPathNormalized in protectedElements: + displayPath: str = relPath.replace("\\", "/") + addReportEntry(f"- **{displayPath}/**: skipped (protected scope)") + else: + dirsToCopy.append(dirName) + dirs[:] = dirsToCopy + + relDst: str = os.path.relpath(root, srcDir) + targetRoot: str = os.path.join(dstDir, relDst) + + fileName: str + for fileName in files: + relPath = fileName if relDir == "." else os.path.join(relDir, fileName) + relPathNormalized = relPath.replace("\\", "/").lower() + if relPathNormalized in protectedElements: + displayPath = relPath.replace("\\", "/") + addReportEntry(f"- **{displayPath}**: skipped (protected scope)") + else: + if not dryRun: + srcFile: str = os.path.join(root, fileName) + dstFile: str = os.path.join(targetRoot, fileName) + os.makedirs(os.path.dirname(dstFile), exist_ok=True) + shutil.copy2(srcFile, dstFile) + + item: str + for item in os.listdir(tempDir): + itemNormalized: str = item.lower() + if itemNormalized in protectedElements: + addReportEntry(f"- **{item}**: skipped (protected scope)") + continue + + if item in ["buildVars.py", "pyproject.toml"]: + continue + + srcItem: str = os.path.join(tempDir, item) + dstItem: str = os.path.join(addonDir, item) + + try: + if os.path.isdir(srcItem): + inspectAndCopyDirectory(srcItem, dstItem) + addReportEntry(f"- **{item}/**: merged safely") + else: + if not dryRun: + shutil.copy2(srcItem, dstItem) + addReportEntry(f"- **{item}**: synchronized") + except Exception as exceptionObj: + addReportEntry(f"- **{item}**: failed ({str(exceptionObj)})") + + logger.info("Processing structural configuration merges...") + templateBuildvars: str = os.path.join(tempDir, "buildVars.py") + templatePyproject: str = os.path.join(tempDir, "pyproject.toml") + + oldBuildvars: str = os.path.join(addonDir, "buildVars.py") + oldPyproject: str = os.path.join(addonDir, "pyproject.toml") + + buildvarsMetadata: dict[str, Any] + buildvarsGlobals: dict[str, tuple[ast.AST, str]] + buildvarsMetadata, buildvarsGlobals = extractBuildvarsMetadata(oldBuildvars) + addonName: Any = buildvarsMetadata.get("addon_name", os.path.basename(addonDir)) + + buildvarsStatus: str = mergeBuildvarsFile(oldBuildvars, templateBuildvars, buildvarsMetadata, buildvarsGlobals, dryRun) + pyprojectStatus: str = mergePyprojectToml(oldPyproject, templatePyproject, buildvarsMetadata, dryRun) + + logger.info("=" * 50) + logger.info("UPDATE REPORT") + logger.info("=" * 50) + logger.info("Add-on: %s", addonName) + logger.info("\nTemplate synchronization:") + entry: str + for entry in sorted(syncReport): + logger.info(" %s", entry) + logger.info( + "\nConfiguration files:\n - **buildVars.py**: %s\n - **pyproject.toml**: %s", + buildvarsStatus, + pyprojectStatus, + ) + + +def main() -> None: + """Execute main CLI entry point for the NVDA Add-on update tool. + + :return: None + """ + logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") + + parser: argparse.ArgumentParser = buildArgParser() + args: argparse.Namespace = parser.parse_args() + + addonDirInput: str | None = args.addonDir + addonDir: str + if addonDirInput: + addonDir = os.path.abspath(addonDirInput) + else: + cwd: Path = Path(os.getcwd()).resolve() + addonRoot: Path | None = 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) + + logger.info("=== NVDA ADD-ON UPDATE TOOL ===") + logger.info("Target Directory: %s", addonDir) + + oldBuildvars: str = os.path.join(addonDir, "buildVars.py") + + if not os.path.exists(oldBuildvars): + 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) + + logger.info("Phase 1: Analyzing existing project structure and metadata...") + buildvarsMetadata: dict[str, Any] + buildvarsMetadata, _ = extractBuildvarsMetadata(oldBuildvars) + addonName: Any = buildvarsMetadata.get("addon_name", os.path.basename(addonDir)) + logger.info("Target Add-on Identified: %s", addonName) + + if args.dryRun: + logger.info("RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") + + logger.info("Phase 2: Safety backup verification...") + if args.dryRun: + logger.info("Safety backup skipped (simulation mode active).") + elif args.skipBackup: + logger.info("Safety backup skipped (--skip-backup requested by user).") + else: + backupDir: str = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + 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_*"), + ) + logger.info("Backup created successfully.") + except Exception as exceptionObj: + logger.error("Critical: Backup failed (%s). Aborting update.", exceptionObj) + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) + + if args.templateDir: + templatePath: str = os.path.abspath(args.templateDir) + logger.info("Phase 3: Using local template directory: %s", templatePath) + if not os.path.exists(os.path.join(templatePath, "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: + logger.info("Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + with tempfile.TemporaryDirectory() as tempDir: + logger.info("Cloning template into temporary workspace...") + templateUrl: str = "https://github.com/nvaccess/AddonTemplate.git" + + try: + subprocess.run( + ["git", "clone", "--depth", "1", templateUrl, tempDir], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + logger.info("Template cloned successfully.") + except (subprocess.CalledProcessError, FileNotFoundError) as exceptionObj: + logger.error("Failed to execute git clone. Make sure Git is available in your PATH.") + if isinstance(exceptionObj, subprocess.CalledProcessError) and exceptionObj.stderr: + logger.error("Details: %s", exceptionObj.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: + logger.info("Project successfully updated. Workspace cleared.") + else: + logger.info("Simulation finished. Workspace cleared.") + + +if __name__ == "__main__": + main() diff --git a/tests/__init__.py b/tests/__init__.py index b7ad83b..91c0863 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,4 +2,8 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Root test package for the add-on template repository.""" +"""Unit test suite for the add-on template infrastructure. + +This package contains automated tests to verify the template configuration +and development tooling. +""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 5d53c4a..91c0863 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,4 +2,8 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Unit tests package.""" +"""Unit test suite for the add-on template infrastructure. + +This package contains automated tests to verify the template configuration +and development tooling. +""" diff --git a/tests/unit/template/sanity.py b/tests/unit/template/sanity.py new file mode 100644 index 0000000..c47b777 --- /dev/null +++ b/tests/unit/template/sanity.py @@ -0,0 +1,24 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Sanity test suite for verifying unit test execution in the add-on template.""" + +import unittest + + +class TestTemplateSanity(unittest.TestCase): + """Minimal sanity test suite to verify the unit test runner setup.""" + + def test_runner_handles_passing_tests(self): + """Ensure that the test runner correctly detects passing tests.""" + self.assertTrue(True) + + @unittest.expectedFailure + def test_runner_handles_failing_tests(self): + """Ensure that the test runner correctly detects failing tests. + + Marked with @expectedFailure so CI remains green while demonstrating + failure detection. + """ + self.assertTrue(False) diff --git a/tests/unit/test_syncAddonWithTemplate.py b/tests/unit/test_syncAddonWithTemplate.py new file mode 100644 index 0000000..240e965 --- /dev/null +++ b/tests/unit/test_syncAddonWithTemplate.py @@ -0,0 +1,277 @@ +# 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 syncAddonWithTemplate.py module.""" + +import tempfile +import unittest +from pathlib import Path + +# Import functions to test +from syncAddonWithTemplate import ( + extractBuildvarsMetadata, + fixTomlIndentation, + formatAuthorList, + mergeBuildvarsFile, + mergeDependencyLists, + mergePyprojectToml, +) + + +def load_tests( + loader: unittest.TestLoader, tests: unittest.TestSuite, pattern: str | None +) -> unittest.TestSuite: + """Protocol function to override default unittest test loading order. + + Enforces test execution in source code definition order using class dict insertion order. + """ + # Python's dir() sorts methods alphabetically by default. We use __dict__ + # to preserve the exact declaration order from the source file. + methodOrder = list(TestSyncAddonWithTemplate.__dict__.keys()) + loader.sortTestMethodsUsing = ( + lambda a, b: methodOrder.index(a) - methodOrder.index(b) + ) + return loader.loadTestsFromTestCase(TestSyncAddonWithTemplate) + + +class TestSyncAddonWithTemplate(unittest.TestCase): + """Test cases for checking synchronization logic and file merges.""" + + def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: + """Ensure legacy buildVars.py is correctly merged into the latest official template structure.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # 1. Legacy dictionary-based buildVars.py + projBvPath.write_text( + 'addon_info = {\n' + ' "addon_name": "dayOfTheWeek",\n' + ' "addon_summary": _("Day of the week"),\n' + ' "addon_version": "20251022.0.1",\n' + '}\n' + 'import os\n' + 'pythonSources = [os.path.join("addon", "globalPlugins", "*.py")]\n' + 'i18nSources = pythonSources + ["buildVars.py"]\n' + 'excludedFiles = []\n' + 'baseLanguage = "en"\n' + 'markdownExtensions = []\n', + encoding="utf-8", + ) + + # 2. Official template buildVars.py content + tplBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="addonTemplate",\n' + ' addon_summary=_("Add-on user visible name"),\n' + ' addon_description=_("""Description."""),\n' + ' addon_version="x.y",\n' + ' addon_changelog=_("""Changelog."""),\n' + ' addon_author="name ",\n' + ' addon_url=None,\n' + ' addon_sourceURL=None,\n' + ' addon_docFileName="readme.html",\n' + ' addon_minimumNVDAVersion=None,\n' + ' addon_lastTestedNVDAVersion=None,\n' + ' addon_updateChannel=None,\n' + ' addon_license=None,\n' + ' addon_licenseURL=None,\n' + ')\n\n' + 'pythonSources: list[str] = []\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n' + 'speechDictionaries: SpeechDictionaries = {}\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content = projBvPath.read_text(encoding="utf-8") + # Verify legacy metadata mapping (handling single quote formatting) + self.assertIn("addon_name='dayOfTheWeek'", content) + self.assertIn("addon_version='20251022.0.1'", content) + # Verify new official template imports and variables + self.assertIn("from site_scons.site_tools.NVDATool.utils import _", content) + self.assertIn("brailleTables: BrailleTables = {}", content) + self.assertIn("symbolDictionaries: SymbolDictionaries = {}", content) + self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + + def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: + """Ensure modern buildVars.py gets missing speechDictionaries injected from official template.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # 1. Modern buildVars.py without speechDictionaries + projBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="dayOfTheWeek",\n' + ' addon_summary=_("Day of the week"),\n' + ' addon_version="20260222.0.0",\n' + ')\n\n' + 'import os\n' + 'pythonSources: list[str] = [os.path.join("addon", "globalPlugins", "*.py")]\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n', + encoding="utf-8", + ) + + # 2. Official template buildVars.py + tplBvPath.write_text( + 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' + 'from site_scons.site_tools.NVDATool.utils import _\n\n' + 'addon_info = AddonInfo(\n' + ' addon_name="addonTemplate",\n' + ' addon_summary=_("Add-on user visible name"),\n' + ' addon_version="x.y",\n' + ')\n\n' + 'pythonSources: list[str] = []\n' + 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' + 'excludedFiles: list[str] = []\n' + 'baseLanguage: str = "en"\n' + 'markdownExtensions: list[str] = []\n' + 'brailleTables: BrailleTables = {}\n' + 'symbolDictionaries: SymbolDictionaries = {}\n' + 'speechDictionaries: SpeechDictionaries = {}\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name='dayOfTheWeek'", content) + self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + + def testMergeBuildvarsAutoImportsOs(self) -> None: + """Ensure 'import os' is automatically added if merged buildVars uses the os module.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath = Path(tempDir) / "buildVars.py" + tplBvPath = Path(tempDir) / "template_buildVars.py" + + # Legacy buildVars using os module without import in template + projBvPath.write_text( + 'import os\n' + 'pythonSources = [os.path.join("addon", "*.py")]\n', + encoding="utf-8", + ) + + tplBvPath.write_text( + 'pythonSources: list[str] = []\n', + encoding="utf-8", + ) + + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) + + content = projBvPath.read_text(encoding="utf-8") + self.assertTrue(content.startswith("import os\n")) + + def testFixTomlIndentation(self) -> None: + """Ensure that 4 spaces are replaced by a tab inside maintainers/authors blocks only.""" + inputToml = ( + 'name = "myAddon"\n' + "maintainers = [\n" + ' {name = "John Doe", email = "john@example.com"},\n' + "]\n" + "otherSection = {\n" + ' key = "value"\n' + "}\n" + ) + expectedOutput = ( + 'name = "myAddon"\n' + "maintainers = [\n" + '\t{name = "John Doe", email = "john@example.com"},\n' + "]\n" + "otherSection = {\n" + ' key = "value"\n' + "}\n" + ) + + result = fixTomlIndentation(inputToml) + self.assertEqual(result, expectedOutput) + + def testFormatAuthorList(self) -> None: + """Ensure raw author string parsing produces a formatted tomlkit array.""" + rawAuthors = "John Doe , Jane Smith" + authorsArray = formatAuthorList(rawAuthors) + + self.assertEqual(len(authorsArray), 2) + self.assertEqual(authorsArray[0]["name"], "John Doe") + self.assertEqual(authorsArray[0]["email"], "john@example.com") + self.assertEqual(authorsArray[1]["name"], "Jane Smith") + self.assertEqual(authorsArray[1]["email"], "") + + def testMergeDependencyLists(self) -> None: + """Ensure dependency lists merge updates existing package versions while preserving custom ones.""" + projDeps = ["pyright>=1.1.0", "requests>=2.28.0", "ruff==0.1.0"] + tplDeps = ["pyright>=1.2.0", "ruff==0.2.0", "pytest"] + + merged = mergeDependencyLists(projDeps, tplDeps) + + # Check that versions from template override project versions + self.assertIn("pyright>=1.2.0", merged) + self.assertNotIn("pyright>=1.1.0", merged) + self.assertIn("ruff==0.2.0", merged) + + # Check that custom dependency is preserved + self.assertIn("requests>=2.28.0", merged) + + # Check that new template dependency is added + self.assertIn("pytest", merged) + + def testMergePyprojectTomlIntelligent(self) -> None: + """Ensure pyproject.toml is intelligently merged without duplicating dependencies.""" + with tempfile.TemporaryDirectory() as tempDir: + projToml = Path(tempDir) / "pyproject.toml" + tplToml = Path(tempDir) / "template_pyproject.toml" + + projToml.write_text( + '[project]\n' + 'name = "myAddon"\n' + 'dependencies = ["requests>=2.0.0", "pyright>=1.0.0"]\n', + encoding="utf-8", + ) + + tplToml.write_text( + '[project]\n' + 'name = "addonTemplate"\n' + 'dependencies = ["pyright>=2.0.0", "ruff"]\n' + '[dependency-groups]\n' + 'dev = ["pytest"]\n', + encoding="utf-8", + ) + + status = mergePyprojectToml(projToml, tplToml, metadata={}, dryRun=False) + self.assertEqual(status, "merged intelligently (tomlkit)") + + content = projToml.read_text(encoding="utf-8") + self.assertIn('name = "myAddon"', content) + self.assertIn('requests>=2.0.0', content) + + +if __name__ == "__main__": + unittest.main() + \ No newline at end of file diff --git a/uv.lock b/uv.lock index 4484ce7..8aa2402 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ source = { editable = "." } build = [ { name = "markdown" }, { name = "scons" }, + { name = "tomlkit" }, ] dev = [ { name = "crowdin-api-client" }, @@ -23,6 +24,7 @@ dev = [ { name = "pyright", extra = ["nodejs"] }, { name = "ruff" }, { name = "scons" }, + { name = "tomlkit" }, { name = "uv" }, ] l10n = [ @@ -46,6 +48,7 @@ lint = [ build = [ { name = "markdown", specifier = "==3.10" }, { name = "scons", specifier = "==4.10.1" }, + { name = "tomlkit", specifier = "==0.15.0" }, ] dev = [ { name = "crowdin-api-client", specifier = "==1.24.1" }, @@ -56,9 +59,10 @@ dev = [ { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, { name = "prek", specifier = "==0.4.8" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, + { name = "tomlkit", specifier = "==0.15.0" }, { name = "uv", specifier = "==0.11.15" }, ] l10n = [ @@ -71,7 +75,7 @@ l10n = [ ] lint = [ { name = "prek", specifier = "==0.4.8" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" }, { name = "ruff", specifier = "==0.14.5" }, { name = "uv", specifier = "==0.11.15" }, ] @@ -289,15 +293,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.407" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [package.optional-dependencies] @@ -355,6 +359,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From 3553ac318cfb4f21f0ab0bbc4079fe6f946e5cda Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 4 Aug 2026 13:41:18 +0200 Subject: [PATCH 02/52] test(sync): update testFormatAuthorList for PEP 621 compliance Update testFormatAuthorList in test_syncAddonWithTemplate.py to assert that empty author email keys are omitted rather than expecting an empty string, matching syncAddonWithTemplate.py behavior. --- tests/unit/test_syncAddonWithTemplate.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/test_syncAddonWithTemplate.py b/tests/unit/test_syncAddonWithTemplate.py index 240e965..9753d73 100644 --- a/tests/unit/test_syncAddonWithTemplate.py +++ b/tests/unit/test_syncAddonWithTemplate.py @@ -222,7 +222,8 @@ def testFormatAuthorList(self) -> None: self.assertEqual(authorsArray[0]["name"], "John Doe") self.assertEqual(authorsArray[0]["email"], "john@example.com") self.assertEqual(authorsArray[1]["name"], "Jane Smith") - self.assertEqual(authorsArray[1]["email"], "") + # Verify that the email key is omitted when empty (PEP 621 / uv compliance) + self.assertNotIn("email", authorsArray[1]) def testMergeDependencyLists(self) -> None: """Ensure dependency lists merge updates existing package versions while preserving custom ones.""" From bc4816d8a3977fbac7391df5bf70ba4123636a29 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 4 Aug 2026 20:24:32 +0200 Subject: [PATCH 03/52] docs(unitTesting): clarify testFormatAuthorList email omission in documentation Update description of testFormatAuthorList to document that empty email fields are omitted for PEP 621 compliance. --- docs/unitTesting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/unitTesting.md b/docs/unitTesting.md index d6af998..2720444 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -46,6 +46,6 @@ The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST * **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. * **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). * **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. -* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs. +* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs, ensuring empty `email` fields are omitted for PEP 621 compliance. * **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. * **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. From 241ee9aecbd87630de5dccfa2f2f27932532c9d4 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 5 Aug 2026 21:34:53 +0200 Subject: [PATCH 04/52] test(infra): adjust test package docstrings and remove redundant sanity test - Move test infrastructure docstring from root unit tests to template submodule. - Revert root `tests/__init__.py` docstring to reflect all test types (system and unit). - Remove obsolete `sanity.py` test file. --- tests/__init__.py | 6 +---- tests/unit/__init__.py | 6 +---- tests/unit/template/sanity.py | 24 ------------------- tests/unit/template/test_sanity.py | 24 ------------------- .../test_syncAddonWithTemplate.py | 0 5 files changed, 2 insertions(+), 58 deletions(-) delete mode 100644 tests/unit/template/sanity.py delete mode 100644 tests/unit/template/test_sanity.py rename tests/unit/{ => template}/test_syncAddonWithTemplate.py (100%) diff --git a/tests/__init__.py b/tests/__init__.py index 91c0863..b7ad83b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -2,8 +2,4 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Unit test suite for the add-on template infrastructure. - -This package contains automated tests to verify the template configuration -and development tooling. -""" +"""Root test package for the add-on template repository.""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py index 91c0863..7e3f33a 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,8 +2,4 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Unit test suite for the add-on template infrastructure. - -This package contains automated tests to verify the template configuration -and development tooling. -""" +"""Unit test suite for the repository.""" diff --git a/tests/unit/template/sanity.py b/tests/unit/template/sanity.py deleted file mode 100644 index c47b777..0000000 --- a/tests/unit/template/sanity.py +++ /dev/null @@ -1,24 +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. - -"""Sanity test suite for verifying unit test execution in the add-on template.""" - -import unittest - - -class TestTemplateSanity(unittest.TestCase): - """Minimal sanity test suite to verify the unit test runner setup.""" - - def test_runner_handles_passing_tests(self): - """Ensure that the test runner correctly detects passing tests.""" - self.assertTrue(True) - - @unittest.expectedFailure - def test_runner_handles_failing_tests(self): - """Ensure that the test runner correctly detects failing tests. - - Marked with @expectedFailure so CI remains green while demonstrating - failure detection. - """ - self.assertTrue(False) diff --git a/tests/unit/template/test_sanity.py b/tests/unit/template/test_sanity.py deleted file mode 100644 index c47b777..0000000 --- a/tests/unit/template/test_sanity.py +++ /dev/null @@ -1,24 +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. - -"""Sanity test suite for verifying unit test execution in the add-on template.""" - -import unittest - - -class TestTemplateSanity(unittest.TestCase): - """Minimal sanity test suite to verify the unit test runner setup.""" - - def test_runner_handles_passing_tests(self): - """Ensure that the test runner correctly detects passing tests.""" - self.assertTrue(True) - - @unittest.expectedFailure - def test_runner_handles_failing_tests(self): - """Ensure that the test runner correctly detects failing tests. - - Marked with @expectedFailure so CI remains green while demonstrating - failure detection. - """ - self.assertTrue(False) diff --git a/tests/unit/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonWithTemplate.py similarity index 100% rename from tests/unit/test_syncAddonWithTemplate.py rename to tests/unit/template/test_syncAddonWithTemplate.py From 81b977904febf29c861622b317add1d343687d7a Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 5 Aug 2026 21:56:02 +0200 Subject: [PATCH 05/52] fix(sync): normalize package names and bootstrap `.addonmergeignore` - Replace underscores with hyphens in `getBasePackageName` and `legacyToolingBases` to handle equivalent Python package name formats during dependency merges. - Bootstrap `.addonmergeignore` from template on first sync if absent locally, allowing it to self-reference and manage its own persistence dynamically. --- .addonmergeignore | 2 ++ syncAddonWithTemplate.py | 27 +++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/.addonmergeignore b/.addonmergeignore index e69de29..e3b05d0 100644 --- a/.addonmergeignore +++ b/.addonmergeignore @@ -0,0 +1,2 @@ +# Files and directories ignored during template synchronization +.addonmergeignore diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 77aba0c..40c7593 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -236,7 +236,8 @@ def getBasePackageName(dependencyString: str) -> str: :return: The normalized base package name in lowercase. """ regexMatch: re.Match[str] | None = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", dependencyString.strip()) - return regexMatch.group(0).lower() if regexMatch else dependencyString.strip().lower() + base: str = regexMatch.group(0) if regexMatch else dependencyString.strip() + return base.lower().replace("_", "-") def replaceAstRange(templateLines: list[str], replacements: dict[tuple[int, int], str]) -> None: @@ -446,7 +447,7 @@ def mergePyprojectToml(projPath: str | Path, tplPath: str | Path, metadata: dict "nh3", "crowdin-api-client", "lxml", - "mdx_truly_sane_lists", + "mdx-truly-sane-lists", "markdown-link-attr-modifier", "mdx-gh-links", "uv", @@ -573,6 +574,26 @@ def mergeBuildvarsFile( return "merged & structured (AST verified)" +def setupAddonMergeIgnore(tempDir: str | Path, addonDir: str | Path, dryRun: bool = False) -> None: + """Ensures .addonmergeignore exists in the target add-on directory. + + If missing, copies it from the template to bootstrap default ignore rules. + Does nothing if the file is already present. + + :param tempDir: Path to the template directory. + :param addonDir: Path to the target add-on directory. + :param dryRun: If True, simulate execution without modifying files. + :return: None + """ + ignoreFilePath: Path = Path(addonDir) / ".addonmergeignore" + templateIgnorePath: Path = Path(tempDir) / ".addonmergeignore" + + if not ignoreFilePath.exists() and templateIgnorePath.exists(): + if not dryRun: + shutil.copy2(templateIgnorePath, ignoreFilePath) + logger.info("Bootstrapped missing .addonmergeignore from template.") + + def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: """Synchronizes template machinery files from the temporary workspace into the target directory. @@ -582,6 +603,8 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: :return: None """ logger.info("Synchronizing template machinery files...") + # Bootstrap missing .addonmergeignore before reading protection rules + setupAddonMergeIgnore(tempDir, addonDir, dryRun) protectedElements: set[str] = { "readme.md", From f64404caa7fe29d67de5ce5c0c9ee90e92ccd087 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 5 Aug 2026 22:05:03 +0200 Subject: [PATCH 06/52] test(sync): make `load_tests` sorting robust against inherited methods - Replace `methodOrder.index()` with `orderIndex.get(a, defaultOrder)` map. - Prevent `ValueError` when running tests on inherited or dynamic methods. --- tests/unit/template/test_syncAddonWithTemplate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/template/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonWithTemplate.py index 9753d73..498b332 100644 --- a/tests/unit/template/test_syncAddonWithTemplate.py +++ b/tests/unit/template/test_syncAddonWithTemplate.py @@ -26,11 +26,11 @@ def load_tests( Enforces test execution in source code definition order using class dict insertion order. """ - # Python's dir() sorts methods alphabetically by default. We use __dict__ + # Python's dir() sorts methods alphabetically by default. We use __dict__ # to preserve the exact declaration order from the source file. - methodOrder = list(TestSyncAddonWithTemplate.__dict__.keys()) + orderIndex = {name: i for i, name in enumerate(TestSyncAddonWithTemplate.__dict__)} loader.sortTestMethodsUsing = ( - lambda a, b: methodOrder.index(a) - methodOrder.index(b) + lambda a, b: orderIndex.get(a, 999) - orderIndex.get(b, 999) ) return loader.loadTestsFromTestCase(TestSyncAddonWithTemplate) From a8ef69466cc364584b694fdc6972fcb800d6f426 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 5 Aug 2026 22:16:41 +0200 Subject: [PATCH 07/52] docs(sync): fix unit test path and CLI usage examples - Fix incorrect path for `test_syncAddonWithTemplate.py` in `unitTesting.md`. - Clarify `tomlkit` installation requirement and fix no-argument command example in `updatingExistingAddons.md`. --- .../updatingExistingAddons.md | 51 +++++++++---------- docs/unitTesting.md | 7 +-- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 1a72804..1cb91af 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -86,16 +86,7 @@ Before running the tool, ensure your system meets the following requirements: Git must be installed and available in your system `PATH`. * For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: - Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be installed in your global Python environment before execution. - This is necessary because legacy add-on repositories do not include this dependency yet, and it is not yet present by default in the template's stable `master` branch. - - To install or update `tomlkit` globally, run the following command in your terminal: - - ```sh - 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`)* + Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). ### Running the automated tool @@ -106,7 +97,7 @@ 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 -ad . + uv run python syncAddonWithTemplate.py ``` 2. **Target Directory Mode (With argument):** @@ -161,28 +152,34 @@ This architectural design allows developers to cleanly decouple their project-sp 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. +* Inside this file, list the names, relative paths, or glob patterns of the files or folders you want the tool to skip during synchronization. +* The file uses standard `.gitignore` pattern matching syntax (parsed via `pathspec`). +* 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 specific workflows, simply add them to the file: -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: +```gitignore +# Preserve local release workflows +.github/workflows/release.yml -```text -# Freeze the synchronization script version -syncAddonWithTemplate.py -# Protect your local merge settings file from being replaced -.addonmergeignore +# Protect custom localized documentation +addon/doc/fr/custom-extra-help.html ``` ##### Crucial Requirements & Design Constraints -1. **Case-Insensitivity:** +1. **Automatic Self-Exclusion:** + The update tool automatically protects `.addonmergeignore` itself from being overwritten during synchronization. Even if `.addonmergeignore` is present in the template repository, the target add-on's local `.addonmergeignore` file is preserved without needing to explicitly list itself. + +2. **Presence Check on Initial Run:** + Before copying or updating any template files, the script explicitly checks whether `.addonmergeignore` is already present or absent at the root of the target add-on repository. If present, its custom rules are loaded immediately before processing any file transfers. + +3. **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:** +4. **File Location Requirement:** The update engine always loads custom exclusions from the target add-on's root folder being updated. Therefore, **the `.addonmergeignore` file must always reside inside the destination add-on directory**, even if you are executing the `syncAddonWithTemplate.py` script from a completely different directory or an external workspace. @@ -197,7 +194,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 -ad . + uv run python syncAddonWithTemplate.py ``` * **Syntax B (Script outside the add-on repository):** @@ -213,7 +210,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 -ad . -td /path/to/local/AddonTemplate + uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate ``` * **Syntax B (Script outside the add-on repository):** @@ -229,7 +226,7 @@ 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 + uv run python syncAddonWithTemplate.py --dry-run ``` * **Syntax B (Script outside the add-on repository):** @@ -245,7 +242,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 -ad . --skip-backup + uv run python syncAddonWithTemplate.py --skip-backup ``` * **Syntax B (Script outside the add-on repository):** @@ -259,7 +256,7 @@ 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 . +uv run --with tomlkit python syncAddonWithTemplate.py ``` --- diff --git a/docs/unitTesting.md b/docs/unitTesting.md index 2720444..e473989 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -26,14 +26,9 @@ Here is what each part of the command does: You can run individual test modules during development by specifying their path: -* **Sanity / Template Tests:** - ``` bash - uv run python -m unittest -v tests/unit/template/test_sanity.py - ``` - * **Add-on Synchronization Tool Tests:** ``` bash - uv run python -m unittest -v tests/unit/update/test_syncAddonWithTemplate.py + uv run python -m unittest -v tests/unit/template/test_syncAddonWithTemplate.py ``` --- From 87372e548bfea828ccbf52f26cda932f1fa69797 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 5 Aug 2026 22:36:17 +0200 Subject: [PATCH 08/52] test: add unit tests and documentation for .addonmergeignore - Add testSetupAddonMergeIgnore and testAddonMergeIgnore to test_syncAddonWithTemplate.py - Document both new tests in docs/unitTesting.md --- docs/unitTesting.md | 2 + .../template/test_syncAddonWithTemplate.py | 84 +++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/docs/unitTesting.md b/docs/unitTesting.md index e473989..3be5977 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -39,6 +39,8 @@ The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST * **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. * **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. +* **`testSetupAddonMergeIgnore`**: Verifies that `.addonmergeignore` is automatically bootstrapped from the template if missing, preserved if already present, and left untouched during dry-run executions. +* **`testAddonMergeIgnore`**: Confirms that files and patterns specified in `.addonmergeignore` are strictly excluded from being overwritten during full add-on synchronization. * **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). * **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. * **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs, ensuring empty `email` fields are omitted for PEP 621 compliance. diff --git a/tests/unit/template/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonWithTemplate.py index 498b332..e686042 100644 --- a/tests/unit/template/test_syncAddonWithTemplate.py +++ b/tests/unit/template/test_syncAddonWithTemplate.py @@ -16,6 +16,8 @@ mergeBuildvarsFile, mergeDependencyLists, mergePyprojectToml, + setupAddonMergeIgnore, + runSynchronization, ) @@ -165,6 +167,88 @@ def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: self.assertIn("addon_name='dayOfTheWeek'", content) self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + def test_setupAddonMergeIgnore(self) -> None: + """Verify bootstrapping of .addonmergeignore from template to add-on directory. + + Tests creation when missing, preservation when existing, and behavior in dry-run mode. + """ + tempDirObj: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory() + self.addCleanup(tempDirObj.cleanup) + tempPath: Path = Path(tempDirObj.name) + + addonDir: Path = tempPath / "myAddon" + templateDir: Path = tempPath / "template" + addonDir.mkdir(parents=True, exist_ok=True) + templateDir.mkdir(parents=True, exist_ok=True) + + # Create template .addonmergeignore + templateIgnore: Path = templateDir / ".addonmergeignore" + templateIgnore.write_text("*.tmp\nbuild/\n", encoding="utf-8") + + addonIgnore: Path = addonDir / ".addonmergeignore" + + # Case 1: Dry run should NOT copy the file + setupAddonMergeIgnore(tempDir=templateDir, addonDir=addonDir, dryRun=True) + self.assertFalse(addonIgnore.exists()) + + # Case 2: Standard execution should copy (bootstrap) the missing file + setupAddonMergeIgnore(tempDir=templateDir, addonDir=addonDir, dryRun=False) + self.assertTrue(addonIgnore.exists()) + self.assertEqual(addonIgnore.read_text(encoding="utf-8"), "*.tmp\nbuild/\n") + + # Case 3: Existing file should NOT be overwritten by template + addonIgnore.write_text("customRule/\n", encoding="utf-8") + setupAddonMergeIgnore(tempDir=templateDir, addonDir=addonDir, dryRun=False) + self.assertEqual(addonIgnore.read_text(encoding="utf-8"), "customRule/\n") + + def test_addonMergeIgnore(self) -> None: + """Verify that files specified in .addonmergeignore are excluded during synchronization. + + Ensures that existing files listed in the ignore file retain their original content + and are not overwritten by template files. + """ + tempDirObj: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory() + self.addCleanup(tempDirObj.cleanup) + tempPath: Path = Path(tempDirObj.name) + + # 1. Setup project directories + addonDir: Path = tempPath / "myAddon" + templateDir: Path = tempPath / "template" + addonDir.mkdir(parents=True, exist_ok=True) + templateDir.mkdir(parents=True, exist_ok=True) + + # 2. Populate template and addon files + normalFileTemplate: Path = templateDir / "normalFile.txt" + normalFileTemplate.write_text("Template content", encoding="utf-8") + + ignoredFileTemplate: Path = templateDir / "ignoredFile.txt" + ignoredFileTemplate.write_text("Template ignored content", encoding="utf-8") + + ignoredFileAddon: Path = addonDir / "ignoredFile.txt" + ignoredFileAddon.write_text("Original addon content", encoding="utf-8") + + # 3. Create .addonmergeignore file in the addon directory + ignoreFile: Path = addonDir / ".addonmergeignore" + ignoreFile.write_text("ignoredFile.txt\n", encoding="utf-8") + + # 4. Execute synchronization with correct arguments (tempDir, addonDir, dryRun) + runSynchronization( + tempDir=str(templateDir), + addonDir=str(addonDir), + dryRun=False, + ) + + # 5. Assertions + normalFileAddon: Path = addonDir / "normalFile.txt" + self.assertTrue(normalFileAddon.exists()) + self.assertEqual(normalFileAddon.read_text(encoding="utf-8"), "Template content") + + # The ignored file must preserve its original content + self.assertEqual( + ignoredFileAddon.read_text(encoding="utf-8"), + "Original addon content", + ) + def testMergeBuildvarsAutoImportsOs(self) -> None: """Ensure 'import os' is automatically added if merged buildVars uses the os module.""" with tempfile.TemporaryDirectory() as tempDir: From 4cbab178243fac8793a48a64cb251977343ff60c Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 6 Aug 2026 22:43:01 +0200 Subject: [PATCH 09/52] tests(template): add missing trailing newline to test_syncAddonWithTemplate.py --- tests/unit/template/test_syncAddonWithTemplate.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/template/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonWithTemplate.py index e686042..3a2497a 100644 --- a/tests/unit/template/test_syncAddonWithTemplate.py +++ b/tests/unit/template/test_syncAddonWithTemplate.py @@ -359,4 +359,3 @@ def testMergePyprojectTomlIntelligent(self) -> None: if __name__ == "__main__": unittest.main() - \ No newline at end of file From 57c18191903a581902f9ae46a9f4196c016ab904 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 6 Aug 2026 23:05:35 +0200 Subject: [PATCH 10/52] docs: restore AddonTemplate URL casing and remove test suite details - Fix casing for AddonTemplate repository URL in updatingExistingAddons.md - Remove detailed syncAddonWithTemplate test suite breakdown in unitTesting.md to keep coverage in docstrings --- docs/managementFromGit/updatingExistingAddons.md | 2 +- docs/unitTesting.md | 13 ------------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 1cb91af..1584b69 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -270,7 +270,7 @@ 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 +git remote add template https://github.com/nvaccess/AddonTemplate.git ``` 2. Fetch the template: diff --git a/docs/unitTesting.md b/docs/unitTesting.md index 3be5977..74ee7fb 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -33,16 +33,3 @@ You can run individual test modules during development by specifying their path: --- -## Synchronization Tool Test Suite Overview (`test_syncAddonWithTemplate.py`) - -The unit test suite covers key logic in `syncAddonWithTemplate.py`, ensuring AST-based config merges, file parsing, and formatting behave predictably across project updates: - -* **`testMergeLegacyBuildvarsWithOfficialTemplate`**: Validates the AST-based migration of legacy dictionary-based `buildVars.py` files into the official modern `AddonInfo` class structure. -* **`testMergeModernBuildvarsMissingSpeechDictionaries`**: Ensures that missing modern attributes (like `speechDictionaries`) are injected into existing `buildVars.py` files without overwriting present configurations. -* **`testSetupAddonMergeIgnore`**: Verifies that `.addonmergeignore` is automatically bootstrapped from the template if missing, preserved if already present, and left untouched during dry-run executions. -* **`testAddonMergeIgnore`**: Confirms that files and patterns specified in `.addonmergeignore` are strictly excluded from being overwritten during full add-on synchronization. -* **`testMergeBuildvarsAutoImportsOs`**: Confirms that `import os` is automatically prepended at the top of the merged `buildVars.py` file if any merged variable uses functions from the `os` module (e.g., `os.path.join`). -* **`testFixTomlIndentation`**: Verifies that 4-space indentations are correctly converted into tabs inside `maintainers` or `authors` TOML array blocks while leaving other sections untouched. -* **`testFormatAuthorList`**: Tests parsing of raw author strings (such as `"Name "`) into `tomlkit` array objects with structured `name` and `email` key-value pairs, ensuring empty `email` fields are omitted for PEP 621 compliance. -* **`testMergeDependencyLists`**: Checks that dependency lists are merged intelligently by base package name, updating outdated tool versions while preserving custom user dependencies. -* **`testMergePyprojectTomlIntelligent`**: Verifies that `pyproject.toml` files are merged using `tomlkit` without creating duplicate dependencies or clobbering existing configuration sections. From f8cd032896f70d4b4af3d6055553ab76bfcad6ee Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 9 Aug 2026 16:15:32 +0200 Subject: [PATCH 11/52] refactor(syncAddonTool): modularize architecture and improve unit tests - Add syncAddonTool package directory to lighten syncAddonWithTemplate.py. - Import submodules from syncAddonTool into syncAddonWithTemplate.py for improved code readability and structure. - Add test fixtures directory to decouple legacy dictionaries and sample files from test_syncAddonWithTemplate.py. - Add unit tests verifying version priority handling between user dependencies and template dependencies. - Add syncAddonTool.spec at repository root to streamline executable generation with PyInstaller. --- syncAddonTool.spec | 116 +++ syncAddonTool/__init__.py | 5 + syncAddonTool/__main__.py | 10 + syncAddonTool/astUtils.py | 75 ++ syncAddonTool/buildVarsSync.py | 167 ++++ syncAddonTool/cli.py | 172 ++++ syncAddonTool/engine.py | 193 ++++ syncAddonTool/pyproject.py | 442 +++++++++ syncAddonTool/utils.py | 130 +++ syncAddonWithTemplate.py | 844 +----------------- .../unit/template/fixtures/legacyBuildVars.py | 11 + .../unit/template/fixtures/modernBuildVars.py | 16 + .../template/fixtures/templateBuildVars.py | 18 + .../template/fixtures/templatePyproject.toml | 17 + .../unit/template/fixtures/userPyproject.toml | 18 + .../template/test_syncAddonWithTemplate.py | 264 +++--- 16 files changed, 1530 insertions(+), 968 deletions(-) create mode 100644 syncAddonTool.spec create mode 100644 syncAddonTool/__init__.py create mode 100644 syncAddonTool/__main__.py create mode 100644 syncAddonTool/astUtils.py create mode 100644 syncAddonTool/buildVarsSync.py create mode 100644 syncAddonTool/cli.py create mode 100644 syncAddonTool/engine.py create mode 100644 syncAddonTool/pyproject.py create mode 100644 syncAddonTool/utils.py create mode 100644 tests/unit/template/fixtures/legacyBuildVars.py create mode 100644 tests/unit/template/fixtures/modernBuildVars.py create mode 100644 tests/unit/template/fixtures/templateBuildVars.py create mode 100644 tests/unit/template/fixtures/templatePyproject.toml create mode 100644 tests/unit/template/fixtures/userPyproject.toml diff --git a/syncAddonTool.spec b/syncAddonTool.spec new file mode 100644 index 0000000..cbb5a27 --- /dev/null +++ b/syncAddonTool.spec @@ -0,0 +1,116 @@ +# -*- mode: python ; coding: utf-8 -*- +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""PyInstaller specification file for syncAddonTool standalone executable.""" + +from pathlib import Path +from PyInstaller.building.api import EXE, PYZ +from PyInstaller.building.build_main import Analysis +from PyInstaller.utils.win32.versioninfo import ( + FixedFileInfo, + StringFileInfo, + StringStruct, + StringTable, + VarFileInfo, + VarStruct, + VSVersionInfo, +) + +# SPECPATH is automatically injected by PyInstaller during spec file execution +ROOT_DIR = Path(SPECPATH).resolve() + +# ----------------------------------------------------------------------------- +# Executable Metadata Configuration (Windows File Properties / Screen Reader) +# ----------------------------------------------------------------------------- +version_info = VSVersionInfo( + ffi=FixedFileInfo( + filevers=(1, 0, 0, 0), + prodvers=(1, 0, 0, 0), + mask=0x3F, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, # VFT_APP + subtype=0x0, + date=(0, 0), + ), + kids=[ + StringFileInfo( + [ + StringTable( + "040904b0", # Unicode / US English + [ + StringStruct("CompanyName", "NV Access Limited, Abdel"), + StringStruct( + "FileDescription", + "NVDA Add-on Template Synchronization Tool", + ), + StringStruct("FileVersion", "1.0.0.0"), + StringStruct("InternalName", "syncAddonTool"), + StringStruct( + "LegalCopyright", + "Copyright (C) 2026 NV Access Limited, Abdel", + ), + StringStruct("OriginalFilename", "syncAddonTool.exe"), + StringStruct("ProductName", "syncAddonTool"), + StringStruct("ProductVersion", "1.0.0.0"), + ], + ) + ] + ), + VarFileInfo([VarStruct("Translation", [1033, 1200])]), + ], +) + +# Generate temporary text version file expected by PyInstaller +VERSION_FILE_PATH = ROOT_DIR / "build" / "version_info.txt" +VERSION_FILE_PATH.parent.mkdir(parents=True, exist_ok=True) +VERSION_FILE_PATH.write_text(str(version_info), encoding="utf-8") + +# ----------------------------------------------------------------------------- +# PyInstaller Analysis and Collection +# ----------------------------------------------------------------------------- +a = Analysis( + ["syncAddonWithTemplate.py"], + pathex=[str(ROOT_DIR)], + binaries=[], + datas=[], + hiddenimports=[ + "syncAddonTool", + "syncAddonTool.engine", + "syncAddonTool.buildVarsSync", + "syncAddonTool.pyproject", + "tomlkit", + "pathspec", + ], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, +) + +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name="syncAddonTool", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + version=str(VERSION_FILE_PATH), +) diff --git a/syncAddonTool/__init__.py b/syncAddonTool/__init__.py new file mode 100644 index 0000000..173b6e8 --- /dev/null +++ b/syncAddonTool/__init__.py @@ -0,0 +1,5 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Core synchronization logic package for NVDA Add-on update tool.""" diff --git a/syncAddonTool/__main__.py b/syncAddonTool/__main__.py new file mode 100644 index 0000000..e145b27 --- /dev/null +++ b/syncAddonTool/__main__.py @@ -0,0 +1,10 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""CLI entry point execution wrapper when invoked with python -m syncAddonTool.""" + +from .cli import main + +if __name__ == "__main__": + main() diff --git a/syncAddonTool/astUtils.py b/syncAddonTool/astUtils.py new file mode 100644 index 0000000..ea8f9ef --- /dev/null +++ b/syncAddonTool/astUtils.py @@ -0,0 +1,75 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Abstract Syntax Tree (AST) parsing and manipulation utilities.""" + +import ast +from typing import Any + + +def parseAstDict(dictNode: ast.Dict) -> dict[str, Any]: + """Extract key-value pairs from an AST Dict node. + + :param dictNode: The ast.Dict node to parse. + :return: A dictionary containing the extracted keys and values. + """ + extractedData: dict[str, Any] = {} + keyNode: ast.expr | None + valNode: ast.expr + for keyNode, valNode in zip(dictNode.keys, dictNode.values): + if keyNode is None: + continue + keyName: Any = getattr(keyNode, "value", None) + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + valValue: Any = getattr(valNode, "value", None) + if keyName is not None: + extractedData[keyName] = valValue + return extractedData + + +def parseAstKeywords(keywordList: list[ast.keyword]) -> dict[str, Any]: + """Extract key-value pairs from a list of AST keyword nodes. + + :param keywordList: The list of ast.keyword nodes to parse. + :return: A dictionary containing the extracted keys and values. + """ + extractedData: dict[str, Any] = {} + keywordItem: ast.keyword + for keywordItem in keywordList: + keyName: str | None = keywordItem.arg + valNode: ast.expr = keywordItem.value + if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": + valNode = valNode.args[0] + valValue: Any = getattr(valNode, "value", None) + if keyName is not None: + extractedData[keyName] = valValue + return extractedData + + +def usesOsModule(astNode: ast.AST) -> bool: + """Check recursively if an AST node contains an actual reference to the 'os' module. + + :param astNode: The AST node to inspect. + :return: True if the node references 'os', False otherwise. + """ + childNode: ast.AST + for childNode in ast.walk(astNode): + if isinstance(childNode, ast.Name) and childNode.id == "os": + return True + return False + + +def replaceAstRange(templateLineList: list[str], replacementMap: dict[tuple[int, int], str]) -> None: + """Apply AST line replacements on a line-by-line list in reverse order. + + :param templateLineList: The list of lines representing the file content. + :param replacementMap: A dictionary mapping (startLine, endLine) tuples to the replacing string. + :return: None + """ + sortedRanges: list[tuple[int, int]] = sorted(replacementMap.keys(), key=lambda rangeTuple: rangeTuple[0], reverse=True) + startLineIndex: int + endLineIndex: int + for startLineIndex, endLineIndex in sortedRanges: + templateLineList[startLineIndex:endLineIndex] = [replacementMap[(startLineIndex, endLineIndex)]] diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py new file mode 100644 index 0000000..26892c8 --- /dev/null +++ b/syncAddonTool/buildVarsSync.py @@ -0,0 +1,167 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Extraction, parsing, and precise AST range merging for buildVars.py.""" + +import ast +import logging +from pathlib import Path +from typing import Any + +from .astUtils import parseAstDict, parseAstKeywords, replaceAstRange, usesOsModule + +logger: logging.Logger = logging.getLogger("syncAddon") + + +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 mapping varName -> (astNode, unparsedExpr). + """ + fileRootPath: Path = Path(filePath) + if not fileRootPath.exists(): + return {}, {} + + with fileRootPath.open("r", encoding="utf-8") as f: + try: + parsedTree: ast.AST = ast.parse(f.read()) + except SyntaxError as syntaxErrorObj: + logger.error("Syntax error while reading %s: %s", fileRootPath, syntaxErrorObj) + return {}, {} + + metadataDict: dict[str, Any] = {} + globalVarsDict: dict[str, tuple[ast.AST, str]] = {} + topLevelVarsSet: set[str] = { + "pythonSources", + "excludedFiles", + "baseLanguage", + "markdownExtensions", + "brailleTables", + "symbolDictionaries", + "speechDictionaries", + } + + astNodeItem: ast.AST + for astNodeItem in ast.walk(parsedTree): + if isinstance(astNodeItem, ast.Assign) and len(astNodeItem.targets) == 1: + assignTarget: ast.expr = astNodeItem.targets[0] + if not isinstance(assignTarget, ast.Name): + continue + varNameText: str = assignTarget.id + + if varNameText == "addon_info": + if isinstance(astNodeItem.value, ast.Dict): + metadataDict.update(parseAstDict(astNodeItem.value)) + elif isinstance(astNodeItem.value, ast.Call) and getattr(astNodeItem.value.func, "id", None) == "AddonInfo": + metadataDict.update(parseAstKeywords(astNodeItem.value.keywords)) + elif varNameText in topLevelVarsSet: + globalVarsDict[varNameText] = (astNodeItem.value, ast.unparse(astNodeItem.value)) + elif isinstance(astNodeItem, ast.AnnAssign): + if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in topLevelVarsSet: + if astNodeItem.value is not None: + globalVarsDict[astNodeItem.target.id] = (astNodeItem.value, ast.unparse(astNodeItem.value)) + + return metadataDict, globalVarsDict + + +def mergeBuildvarsFile( + projFilePath: str | Path, + tplFilePath: str | Path, + metadataDict: dict[str, Any], + globalVarsDict: dict[str, tuple[ast.AST, str]], + dryRun: bool = False, +) -> str: + """Merge template buildVars.py using precise AST range tracking to prevent multiline leaks. + + :param projFilePath: Path to the existing buildVars.py file. + :param tplFilePath: Path to the template buildVars.py file. + :param metadataDict: Dictionary containing metadata values to update. + :param globalVarsDict: Dictionary containing global variables mapping varName -> (astNode, unparsedExpr). + :param dryRun: If True, simulate the merge without writing changes to disk. + :return: A string indicating the result of the merge operation. + """ + templatePathObj: Path = Path(tplFilePath) + projectPathObj: Path = Path(projFilePath) + + if not templatePathObj.exists(): + return "failed (no template found)" + + with templatePathObj.open("r", encoding="utf-8") as f: + tplContentText: str = f.read() + + try: + parsedTree: ast.AST = ast.parse(tplContentText) + except SyntaxError as syntaxErr: + return f"failed (template syntax error: {syntaxErr})" + + templateLineList: list[str] = tplContentText.splitlines(keepends=True) + replacementMap: dict[tuple[int, int], str] = {} + requiresOsImport: bool = False + + astNodeItem: ast.AST + for astNodeItem in ast.walk(parsedTree): + if isinstance(astNodeItem, ast.Call) and getattr(astNodeItem.func, "id", None) == "AddonInfo": + kwItem: ast.keyword + for kwItem in astNodeItem.keywords: + if kwItem.arg in metadataDict: + keyName: str = kwItem.arg + valueVal: Any = metadataDict[keyName] + formattedValueText: str + if valueVal is None: + formattedValueText = "None" + elif isinstance(valueVal, str): + isTranslatable: bool = keyName in ["addon_summary", "addon_description", "addon_changelog"] + formattedValueText = f"_({valueVal!r})" if isTranslatable else repr(valueVal) + else: + formattedValueText = str(valueVal) + + if kwItem.end_lineno is not None: + lineContentText: str = templateLineList[kwItem.lineno - 1] + indentText: str = lineContentText[: len(lineContentText) - len(lineContentText.lstrip())] + replacementMap[(kwItem.lineno - 1, kwItem.end_lineno)] = ( + f"{indentText}{keyName}={formattedValueText},\n" + ) + + elif isinstance(astNodeItem, ast.Assign) and len(astNodeItem.targets) == 1: + assignTarget: ast.expr = astNodeItem.targets[0] + if isinstance(assignTarget, ast.Name) and assignTarget.id in globalVarsDict: + keyName = assignTarget.id + valAstNode: ast.AST + valExprText: str + valAstNode, valExprText = globalVarsDict[keyName] + if usesOsModule(valAstNode): + requiresOsImport = True + if astNodeItem.end_lineno is not None: + lineContentText = templateLineList[astNodeItem.lineno - 1] + indentText = lineContentText[: len(lineContentText) - len(lineContentText.lstrip())] + replacementMap[(astNodeItem.lineno - 1, astNodeItem.end_lineno)] = ( + f"{indentText}{keyName} = {valExprText}\n" + ) + + elif isinstance(astNodeItem, ast.AnnAssign): + if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in globalVarsDict: + keyName = astNodeItem.target.id + valAstNode, valExprText = globalVarsDict[keyName] + if usesOsModule(valAstNode): + requiresOsImport = True + if astNodeItem.end_lineno is not None: + lineContentText = templateLineList[astNodeItem.lineno - 1] + indentText = lineContentText[: len(lineContentText) - len(lineContentText.lstrip())] + typeAnnotationText: str = ast.unparse(astNodeItem.annotation) + replacementMap[(astNodeItem.lineno - 1, astNodeItem.end_lineno)] = ( + f"{indentText}{keyName}: {typeAnnotationText} = {valExprText}\n" + ) + + replaceAstRange(templateLineList, replacementMap) + + if requiresOsImport: + hasOsImport: bool = any("import os" in line for line in templateLineList[:15]) + if not hasOsImport: + templateLineList.insert(0, "import os\n") + + if not dryRun: + with projectPathObj.open("w", encoding="utf-8") as f: + f.writelines(templateLineList) + return "merged & structured (AST verified)" diff --git a/syncAddonTool/cli.py b/syncAddonTool/cli.py new file mode 100644 index 0000000..f1b2044 --- /dev/null +++ b/syncAddonTool/cli.py @@ -0,0 +1,172 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Command-line interface and main entry point orchestration.""" + +import argparse +import logging +import os +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any + +from .buildVarsSync import extractBuildvarsMetadata +from .engine import runSynchronization + +logger: logging.Logger = logging.getLogger("syncAddon") + + +def buildArgParser() -> argparse.ArgumentParser: + """Build and configure the command-line argument parser. + + :return: Configured ArgumentParser object. + """ + parser: argparse.ArgumentParser = 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.", + ) + parser.add_argument( + "-v", + "--verbose", + dest="verbose", + action="store_true", + help="Enable verbose debug logs.", + ) + return parser + + +def main() -> None: + """Execute main CLI entry point for the NVDA Add-on update tool. + + :return: None + """ + parserObj: argparse.ArgumentParser = buildArgParser() + parsedArgs: argparse.Namespace = parserObj.parse_args() + + loggingLevel: int = logging.DEBUG if parsedArgs.verbose else logging.INFO + logging.basicConfig(level=loggingLevel, format="[%(levelname)s] %(message)s") + + addonDirInputText: str | None = parsedArgs.addonDir + addonDir: str + if addonDirInputText: + addonDir = os.path.abspath(addonDirInputText) + else: + cwdPath: Path = Path(os.getcwd()).resolve() + addonRootPath: Path | None = next( + (p for p in (cwdPath, *cwdPath.parents) if (p / "buildVars.py").exists()), + None, + ) + addonDir = str(addonRootPath) if addonRootPath is not None else str(cwdPath) + + logger.info("=== NVDA ADD-ON UPDATE TOOL ===") + logger.info("Target Directory: %s", addonDir) + + oldBuildvarsPath: str = os.path.join(addonDir, "buildVars.py") + + if not os.path.exists(oldBuildvarsPath): + 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) + + logger.info("Phase 1: Analyzing existing project structure and metadata...") + buildvarsMetadataDict: dict[str, Any] + buildvarsMetadataDict, _ = extractBuildvarsMetadata(oldBuildvarsPath) + addonNameVal: Any = buildvarsMetadataDict.get("addon_name", os.path.basename(addonDir)) + logger.info("Target Add-on Identified: %s", addonNameVal) + + if parsedArgs.dryRun: + logger.info("RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") + + logger.info("Phase 2: Safety backup verification...") + if parsedArgs.dryRun: + logger.debug("Safety backup skipped (simulation mode active).") + elif parsedArgs.skipBackup: + logger.debug("Safety backup skipped (--skip-backup requested by user).") + else: + backupDirPath: str = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + logger.info("Creating safety automatic backup in: %s...", os.path.basename(backupDirPath)) + try: + shutil.copytree( + addonDir, + backupDirPath, + ignore=shutil.ignore_patterns(".git", "__pycache__", ".venv", "*_bak_*"), + ) + logger.info("Backup created successfully.") + except Exception as exceptionObj: + logger.error("Critical: Backup failed (%s). Aborting update.", exceptionObj) + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) + + if parsedArgs.templateDir: + templatePath: str = os.path.abspath(parsedArgs.templateDir) + logger.info("Phase 3: Using local template directory: %s", templatePath) + if not os.path.exists(os.path.join(templatePath, "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, parsedArgs.dryRun) + else: + logger.info("Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") + with tempfile.TemporaryDirectory() as tempDir: + logger.debug("Cloning template into temporary workspace...") + templateUrlText: str = "https://github.com/nvaccess/AddonTemplate.git" + + try: + subprocess.run( + ["git", "clone", "--depth", "1", templateUrlText, tempDir], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + ) + logger.info("Template cloned successfully.") + except (subprocess.CalledProcessError, FileNotFoundError) as exceptionObj: + logger.error("Failed to execute git clone. Make sure Git is available in your PATH.") + if isinstance(exceptionObj, subprocess.CalledProcessError) and exceptionObj.stderr: + logger.error("Details: %s", exceptionObj.stderr.decode("utf-8", errors="ignore")) + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) + + runSynchronization(tempDir, addonDir, parsedArgs.dryRun) + + if not parsedArgs.dryRun: + logger.info("Project successfully updated. Workspace cleared.") + else: + logger.info("Simulation finished. Workspace cleared.") diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py new file mode 100644 index 0000000..cfaf60d --- /dev/null +++ b/syncAddonTool/engine.py @@ -0,0 +1,193 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""File tree traversal, file copying, ignore handling, and synchronization orchestration.""" + +import ast +import logging +import os +from pathlib import Path +import shutil +from typing import Any + +from .buildVarsSync import extractBuildvarsMetadata, mergeBuildvarsFile +from .pyproject import mergePyprojectToml + +logger: logging.Logger = logging.getLogger("syncAddon") + + +def setupAddonMergeIgnore(tempDir: str | Path, addonDir: str | Path, dryRun: bool = False) -> None: + """Ensure .addonmergeignore exists in the target add-on directory. + + If missing, copies it from the template to bootstrap default ignore rules. + Does nothing if the file is already present. + + :param tempDir: Path to the template directory. + :param addonDir: Path to the target add-on directory. + :param dryRun: If True, simulate execution without modifying files. + :return: None + """ + ignoreFilePath: Path = Path(addonDir) / ".addonmergeignore" + templateIgnorePath: Path = Path(tempDir) / ".addonmergeignore" + + if not ignoreFilePath.exists() and templateIgnorePath.exists(): + if not dryRun: + shutil.copy2(templateIgnorePath, ignoreFilePath) + logger.info("Bootstrapped missing .addonmergeignore from template.") + + +def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: + """Synchronize 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. + :return: None + """ + logger.info("Phase 4: Synchronizing template machinery files...") + setupAddonMergeIgnore(tempDir, addonDir, dryRun) + + protectedElementsSet: set[str] = { + "readme.md", + "changelog.md", + "addontemplate.egg-info", + ".github/workflows/unittests.yml", + "addon", + ".git", + "__pycache__", + ".venv", + "docs", + ".ruff_cache", + "tests", + } + + ignoreFilePath: str = os.path.join(addonDir, ".addonmergeignore") + if os.path.exists(ignoreFilePath): + logger.debug("Reading local custom exclusions from .addonmergeignore...") + try: + with open(ignoreFilePath, "r", encoding="utf-8") as f: + lineItem: str + for lineItem in f: + cleanLineText: str = lineItem.strip().replace("\\", "/").lower() + if cleanLineText and not cleanLineText.startswith("#"): + protectedElementsSet.add(cleanLineText) + except Exception as exceptionObj: + logger.warning("Failed to parse .addonmergeignore (%s)", exceptionObj) + + syncReportList: list[str] = [] + + def addReportEntry(reportEntryText: str) -> None: + """Add an entry to syncReportList ensuring no duplicates exist. + + :param reportEntryText: The status report line to record. + :return: None + """ + if reportEntryText not in syncReportList: + syncReportList.append(reportEntryText) + + def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: + """Inspect directory recursively for protected elements and copy non-protected files. + + :param srcDirPath: Path to the source directory to inspect. + :param dstDirPath: Path to the destination target directory. + :return: None + """ + walkRoot: str + walkDirs: list[str] + walkFiles: list[str] + for walkRoot, walkDirs, walkFiles in os.walk(srcDirPath): + relDirPath: str = os.path.relpath(walkRoot, tempDir) + + dirsToCopyList: list[str] = [] + dirNameItem: str + for dirNameItem in walkDirs: + relPathText: str = dirNameItem if relDirPath == "." else os.path.join(relDirPath, dirNameItem) + relPathNormalizedText: str = relPathText.replace("\\", "/").lower() + if relPathNormalizedText in protectedElementsSet: + displayPathText: str = relPathText.replace("\\", "/") + addReportEntry(f"- **{displayPathText}/**: skipped (protected scope)") + else: + dirsToCopyList.append(dirNameItem) + walkDirs[:] = dirsToCopyList + + relDstPath: str = os.path.relpath(walkRoot, srcDirPath) + targetRootPath: str = os.path.join(dstDirPath, relDstPath) + + fileNameItem: str + for fileNameItem in walkFiles: + relPathText = fileNameItem if relDirPath == "." else os.path.join(relDirPath, fileNameItem) + relPathNormalizedText = relPathText.replace("\\", "/").lower() + if relPathNormalizedText in protectedElementsSet: + displayPathText = relPathText.replace("\\", "/") + addReportEntry(f"- **{displayPathText}**: skipped (protected scope)") + else: + if not dryRun: + srcFilePath: str = os.path.join(walkRoot, fileNameItem) + dstFilePath: str = os.path.join(targetRootPath, fileNameItem) + os.makedirs(os.path.dirname(dstFilePath), exist_ok=True) + shutil.copy2(srcFilePath, dstFilePath) + + rootItemName: str + for rootItemName in os.listdir(tempDir): + itemNormalizedText: str = rootItemName.lower() + if itemNormalizedText in protectedElementsSet: + addReportEntry(f"- **{rootItemName}**: skipped (protected scope)") + continue + + if rootItemName in ["buildVars.py", "pyproject.toml"]: + continue + + srcItemPath: str = os.path.join(tempDir, rootItemName) + dstItemPath: str = os.path.join(addonDir, rootItemName) + + try: + if os.path.isdir(srcItemPath): + inspectAndCopyDirectory(srcItemPath, dstItemPath) + addReportEntry(f"- **{rootItemName}/**: merged safely") + else: + if not dryRun: + shutil.copy2(srcItemPath, dstItemPath) + addReportEntry(f"- **{rootItemName}**: synchronized") + except Exception as exceptionObj: + addReportEntry(f"- **{rootItemName}**: failed ({str(exceptionObj)})") + + logger.info("Processing structural configuration merges...") + templateBuildvarsPath: str = os.path.join(tempDir, "buildVars.py") + templatePyprojectPath: str = os.path.join(tempDir, "pyproject.toml") + + oldBuildvarsPath: str = os.path.join(addonDir, "buildVars.py") + oldPyprojectPath: str = os.path.join(addonDir, "pyproject.toml") + + buildvarsMetadataDict: dict[str, Any] + buildvarsGlobalsDict: dict[str, tuple[ast.AST, str]] + buildvarsMetadataDict, buildvarsGlobalsDict = extractBuildvarsMetadata(oldBuildvarsPath) + addonNameVal: Any = buildvarsMetadataDict.get("addon_name", os.path.basename(addonDir)) + + buildvarsStatusText: str = mergeBuildvarsFile( + oldBuildvarsPath, + templateBuildvarsPath, + buildvarsMetadataDict, + buildvarsGlobalsDict, + dryRun, + ) + pyprojectStatusText: str = mergePyprojectToml( + oldPyprojectPath, + templatePyprojectPath, + buildvarsMetadataDict, + dryRun, + ) + + logger.info("=" * 50) + logger.info("UPDATE REPORT") + logger.info("=" * 50) + logger.info("Add-on: %s", addonNameVal) + logger.info("\nTemplate synchronization:") + reportEntryItem: str + for reportEntryItem in sorted(syncReportList): + logger.info(" %s", reportEntryItem) + logger.info( + "\nConfiguration files:\n - **buildVars.py**: %s\n - **pyproject.toml**: %s", + buildvarsStatusText, + pyprojectStatusText, + ) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py new file mode 100644 index 0000000..7464149 --- /dev/null +++ b/syncAddonTool/pyproject.py @@ -0,0 +1,442 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""TOML manipulation and pyproject.toml merging algorithms.""" + +from collections.abc import MutableMapping, MutableSequence +import logging +from pathlib import Path +import re +from typing import Any, cast + +import tomlkit + +from .utils import ( + cleanupPlaceholderAuthors, + fixTomlIndentation, + formatAuthorList, + getBasePackageName, + insertKeyAfter, +) + +# Map legacy tools to their modern template equivalents (e.g. pre-commit -> prek) +REPLACED_PACKAGES: dict[str, str] = { + "pre-commit": "prek", +} + +logger: logging.Logger = logging.getLogger("syncAddon") + + +def createPyprojectFromTemplate(templateFilePath: Path, metadataDict: dict[str, Any]) -> tomlkit.TOMLDocument: + """Create a new pyproject.toml document based on the official template, preserving tab indentation. + + :param templateFilePath: Path to the reference template pyproject.toml file. + :param metadataDict: Extracted metadata dictionary from legacy buildVars/manifest. + :return: A tomlkit TOMLDocument adhering to template tab formatting and populated with metadata. + """ + with templateFilePath.open("r", encoding="utf-8") as f: + tomlDoc: tomlkit.TOMLDocument = tomlkit.parse(f.read()) + + if "project" not in tomlDoc: + tomlDoc["project"] = tomlkit.table() + + projectSection: Any = tomlDoc["project"] + + if "addon_name" in metadataDict and metadataDict["addon_name"]: + projectSection["name"] = metadataDict["addon_name"] + + if "addon_summary" in metadataDict and metadataDict["addon_summary"]: + projectSection["description"] = metadataDict["addon_summary"] + + addonUrlText: str = str(metadataDict.get("addon_url", "")).strip() + if addonUrlText: + if "urls" not in projectSection: + projectSection["urls"] = tomlkit.table() + projectSection["urls"]["Repository"] = addonUrlText + + if "addon_author" in metadataDict and metadataDict["addon_author"]: + projectSection["maintainers"] = formatAuthorList(metadataDict["addon_author"]) + + return tomlDoc + + +def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: + """Intelligently merge two dependency lists by updating package versions based on base names. + + Preserves custom user dependencies and retains user version constraints strictly + if they are higher (>). Otherwise, updates to the template version. Handles replaced + packages (e.g. pre-commit -> prek). + + :param projList: The existing project's dependency list. + :param tplList: The template's dependency list. + :return: A merged list with updated versions and preserved custom or newer user items. + """ + logger.debug("Merging dependency lists. User count: %d, Template count: %d", len(projList), len(tplList)) + + projIndexByBase: dict[str, int] = {} + itemIndex: int + depItem: Any + for itemIndex, depItem in enumerate(projList): + if isinstance(depItem, str): + baseName: str = getBasePackageName(depItem) + canonicalBaseName: str = REPLACED_PACKAGES.get(baseName, baseName) + projIndexByBase[canonicalBaseName] = itemIndex + + mergedList: list[Any] = [] + handledUserIndices: set[int] = set() + + tplItem: Any + for tplItem in tplList: + if isinstance(tplItem, str): + tplBaseName: str = getBasePackageName(tplItem) + if tplBaseName in projIndexByBase: + targetIndex: int = projIndexByBase[tplBaseName] + handledUserIndices.add(targetIndex) + userItemText: str = str(projList[targetIndex]) + userOriginalBaseName: str = getBasePackageName(userItemText) + + if userOriginalBaseName in REPLACED_PACKAGES: + logger.debug( + "DECISION [%s]: PACKAGE REPLACED (%s -> %s), FORCING TEMPLATE VERSION -> %r", + tplBaseName, + userOriginalBaseName, + tplBaseName, + tplItem, + ) + mergedList.append(tplItem) + continue + + userMatch: re.Match[str] | None = re.search(r"([0-9]+(?:\.[0-9]+)+)", userItemText) + tplMatch: re.Match[str] | None = re.search(r"([0-9]+(?:\.[0-9]+)+)", tplItem) + + if userMatch and tplMatch: + try: + userVersionTuple: tuple[int, ...] = tuple(map(int, userMatch.group(1).split("."))) + tplVersionTuple: tuple[int, ...] = tuple(map(int, tplMatch.group(1).split("."))) + + if userVersionTuple > tplVersionTuple: + logger.debug( + "DECISION [%s]: KEEP USER VERSION (%s > %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + userItemText, + ) + mergedList.append(userItemText) + continue + else: + logger.debug( + "DECISION [%s]: USE TEMPLATE VERSION (%s <= %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + tplItem, + ) + except ValueError: + pass + + mergedList.append(tplItem) + else: + logger.debug("DECISION [%s]: ADD TEMPLATE DEPENDENCY %r", tplBaseName, tplItem) + mergedList.append(tplItem) + else: + if tplItem not in mergedList: + mergedList.append(tplItem) + + for itemIndex, depItem in enumerate(projList): + if itemIndex not in handledUserIndices: + if isinstance(depItem, str): + baseName = getBasePackageName(depItem) + if baseName in REPLACED_PACKAGES: + logger.debug("DECISION [%s]: REPLACED BY TEMPLATE EQUIVALENT %r", baseName, REPLACED_PACKAGES[baseName]) + continue + logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY %r", depItem) + mergedList.append(depItem) + + return mergedList + + +def deepMergeDicts(projDict: dict[str, Any], tplDict: dict[str, Any]) -> dict[str, Any]: + """Recursively merge tplDict into projDict. + + :param projDict: The original project dictionary to be updated. + :param tplDict: The template dictionary whose values will be merged into projDict. + :return: The updated projDict with merged values from tplDict. + """ + dictKey: str + dictValue: Any + for dictKey, dictValue in tplDict.items(): + if dictKey in projDict: + projVal: Any = projDict[dictKey] + if isinstance(projVal, MutableMapping) and isinstance(dictValue, MutableMapping): + deepMergeDicts(projVal, dictValue) + elif isinstance(projVal, MutableSequence) and isinstance(dictValue, MutableSequence): + projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue)) + else: + pass + else: + projDict[dictKey] = dictValue + return projDict + + +def processDependencyGroupsMigration( + mergedDictData: dict[str, Any], + userFlatDependenciesList: list[Any], +) -> None: + """Migrate legacy flat project dependencies into modern template dependency-groups. + + Updates group versions if user versions are strictly higher, and purges migrated + items from project.dependencies to prevent unsatisfiable uv sync conflicts. + + :param mergedDictData: The merged TOML structure dictionary. + :param userFlatDependenciesList: The original list of flat dependencies from user's project. + :return: None + """ + if "dependency-groups" not in mergedDictData: + return + + userDepsByBaseName: dict[str, str] = {} + depItem: Any + for depItem in userFlatDependenciesList: + if isinstance(depItem, str): + baseName: str = getBasePackageName(depItem) + canonicalBaseName: str = REPLACED_PACKAGES.get(baseName, baseName) + userDepsByBaseName[canonicalBaseName] = depItem + + migratedBaseNamesSet: set[str] = set() + depGroupsTable: dict[str, Any] = mergedDictData["dependency-groups"] + + groupName: str + groupList: Any + for groupName, groupList in list(depGroupsTable.items()): + if not isinstance(groupList, list): + continue + + updatedGroupList: list[Any] = [] + groupItem: Any + for groupItem in groupList: + if isinstance(groupItem, str): + tplBaseName: str = getBasePackageName(groupItem) + if tplBaseName in userDepsByBaseName: + migratedBaseNamesSet.add(tplBaseName) + userDepText: str = userDepsByBaseName[tplBaseName] + userOriginalBaseName: str = getBasePackageName(userDepText) + + if userOriginalBaseName in REPLACED_PACKAGES: + logger.debug( + "DECISION [%s in %s]: PACKAGE REPLACED (%s -> %s), FORCING TEMPLATE VERSION -> %r", + tplBaseName, + groupName, + userOriginalBaseName, + tplBaseName, + groupItem, + ) + updatedGroupList.append(groupItem) + continue + + userMatch: re.Match[str] | None = re.search(r"([0-9]+(?:\.[0-9]+)+)", userDepText) + tplMatch: re.Match[str] | None = re.search(r"([0-9]+(?:\.[0-9]+)+)", groupItem) + + if userMatch and tplMatch: + try: + userVersionTuple: tuple[int, ...] = tuple(map(int, userMatch.group(1).split("."))) + tplVersionTuple: tuple[int, ...] = tuple(map(int, tplMatch.group(1).split("."))) + + if userVersionTuple > tplVersionTuple: + logger.debug( + "DECISION [%s in %s]: KEEP USER VERSION (%s > %s) -> %r", + tplBaseName, + groupName, + userVersionTuple, + tplVersionTuple, + userDepText, + ) + updatedGroupList.append(userDepText) + continue + else: + logger.debug( + "DECISION [%s in %s]: USE TEMPLATE VERSION (%s <= %s) -> %r", + tplBaseName, + groupName, + userVersionTuple, + tplVersionTuple, + groupItem, + ) + except ValueError: + pass + + updatedGroupList.append(groupItem) + else: + updatedGroupList.append(groupItem) + else: + updatedGroupList.append(groupItem) + + depGroupsTable[groupName] = updatedGroupList + + if "project" in mergedDictData and "dependencies" in mergedDictData["project"]: + projectDepsList: list[Any] = mergedDictData["project"]["dependencies"] + filteredProjectDepsList: list[Any] = [] + for depItem in projectDepsList: + if isinstance(depItem, str): + baseName = getBasePackageName(depItem) + canonicalBaseName = REPLACED_PACKAGES.get(baseName, baseName) + if canonicalBaseName in migratedBaseNamesSet: + logger.debug("Purging migrated dependency from flat list: %r", depItem) + continue + filteredProjectDepsList.append(depItem) + + mergedDictData["project"]["dependencies"] = filteredProjectDepsList + + +def mergePyprojectToml( + projFilePath: str | Path, + tplFilePath: str | Path, + metadataDict: dict[str, Any], + dryRun: bool = False, +) -> str: + """Merge template pyproject.toml configuration into the developer's file. + + Handles package replacements (e.g. pre-commit -> prek), dependency-groups migration, + and preserves custom add-on dependencies or higher versions. + + :param projFilePath: Path to the existing pyproject.toml file. + :param tplFilePath: Path to the template pyproject.toml file. + :param metadataDict: 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. + """ + templatePathObj: Path = Path(tplFilePath) + projectPathObj: Path = Path(projFilePath) + + if not templatePathObj.exists(): + return "skipped (no template found)" + + if not projectPathObj.exists(): + try: + projTomlData: tomlkit.TOMLDocument = createPyprojectFromTemplate(templatePathObj, metadataDict) + if not dryRun: + tomlFormattedText: str = fixTomlIndentation(tomlkit.dumps(projTomlData)) + projectPathObj.parent.mkdir(parents=True, exist_ok=True) + with projectPathObj.open("w", encoding="utf-8") as f: + f.write(tomlFormattedText) + logger.info("Created missing pyproject.toml at %s", projectPathObj) + return "created from template" + except Exception as exceptionObj: + logger.error("Failed to create pyproject.toml: %s", exceptionObj) + return f"failed to create from template ({str(exceptionObj)})" + + try: + with projectPathObj.open("r", encoding="utf-8") as f: + projTomlData = tomlkit.parse(f.read()) + with templatePathObj.open("r", encoding="utf-8") as f: + tplTomlData: tomlkit.TOMLDocument = tomlkit.parse(f.read()) + + userFlatDepsList: list[Any] = [] + if "project" in projTomlData and "dependencies" in projTomlData["project"]: + userFlatDepsList = list(projTomlData["project"]["dependencies"]) + + wasOriginallyNvaccess: bool = False + if "project" in projTomlData: + fieldName: str + for fieldName in ["authors", "maintainers"]: + if fieldName in projTomlData["project"] and isinstance( + projTomlData["project"][fieldName], (list, MutableSequence) + ): + authorItem: Any + for authorItem in projTomlData["project"][fieldName]: + authorName: Any = authorItem.get("name", "") if hasattr(authorItem, "get") else "" + if not authorName and isinstance(authorItem, dict): + authorName = authorItem.get("name", "") + if str(authorName).strip().lower() in ["nv access", "nvaccess"]: + wasOriginallyNvaccess = True + break + + mergedDictData: dict[str, Any] = deepMergeDicts( + cast(dict[str, Any], projTomlData), cast(dict[str, Any], tplTomlData) + ) + + processDependencyGroupsMigration(mergedDictData, userFlatDepsList) + + if "project" in mergedDictData: + projectSectionDict: dict[str, Any] = mergedDictData["project"] + + if not wasOriginallyNvaccess: + cleanupPlaceholderAuthors(projectSectionDict) + + finalDoc: tomlkit.TOMLDocument = tomlkit.document() + sectionKey: str + sectionVal: Any + for sectionKey, sectionVal in mergedDictData.items(): + finalDoc[sectionKey] = sectionVal + + if "project" in finalDoc: + projectTable: Any = finalDoc["project"] + for fieldName in ["maintainers", "authors"]: + if fieldName in projectTable and isinstance(projectTable[fieldName], list): + rawAuthorsList: list[str] = [] + for authorEntry in projectTable[fieldName]: + if isinstance(authorEntry, dict): + nameText: str = authorEntry.get("name", "") + emailText: str = authorEntry.get("email", "") + rawAuthorsList.append(f"{nameText} <{emailText}>" if emailText else nameText) + if rawAuthorsList: + formattedVal: tomlkit.items.Array = formatAuthorList(", ".join(rawAuthorsList)) + if fieldName == "maintainers" and "description" in projectTable: + insertKeyAfter( + projectTable, + targetKey="description", + newKey="maintainers", + value=formattedVal, + ) + else: + projectTable[fieldName] = formattedVal + + def makeMultilineArray(containerObj: Any, keyName: str) -> None: + """Force specified array attributes to multiline TOML layout with proper newline separation. + + :param containerObj: The container object holding the key. + :param keyName: The key name within the container to convert. + :return: None + """ + if keyName in containerObj and isinstance(containerObj[keyName], list): + arrayObj: tomlkit.items.Array = tomlkit.array() + arrayObj.multiline(True) + for elementItem in containerObj[keyName]: + if isinstance(elementItem, (dict, MutableMapping)): + inlineTab: tomlkit.items.InlineTable = tomlkit.inline_table() + inlineTab.update(dict(elementItem)) + arrayObj.append(inlineTab) + else: + arrayObj.append(elementItem) + containerObj[keyName] = arrayObj + + if "project" in finalDoc: + makeMultilineArray(finalDoc["project"], "classifiers") + makeMultilineArray(finalDoc["project"], "dependencies") + + if "dependency-groups" in finalDoc: + depGroupsSection: Any = finalDoc["dependency-groups"] + groupKey: str + for groupKey in depGroupsSection: + makeMultilineArray(depGroupsSection, groupKey) + + if "tool" in finalDoc: + toolSection: Any = finalDoc["tool"] + if "ruff" in toolSection: + makeMultilineArray(toolSection["ruff"], "builtins") + makeMultilineArray(toolSection["ruff"], "include") + makeMultilineArray(toolSection["ruff"], "exclude") + if "pyright" in toolSection: + makeMultilineArray(toolSection["pyright"], "include") + makeMultilineArray(toolSection["pyright"], "exclude") + makeMultilineArray(toolSection["pyright"], "extraPaths") + + if not dryRun: + tomlFormattedText: str = fixTomlIndentation(tomlkit.dumps(finalDoc)) + with projectPathObj.open("w", encoding="utf-8") as f: + f.write(tomlFormattedText) + return "merged intelligently (tomlkit)" + except Exception as exceptionObj: + logger.error("Failed to merge pyproject.toml: %s", exceptionObj) + return f"failed to merge ({str(exceptionObj)})" diff --git a/syncAddonTool/utils.py b/syncAddonTool/utils.py new file mode 100644 index 0000000..46dc88b --- /dev/null +++ b/syncAddonTool/utils.py @@ -0,0 +1,130 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""General string, formatting, and structural data helpers.""" + +from collections.abc import MutableSequence +import re +from typing import Any + +import tomlkit + + +def formatAuthorList(rawAuthorsText: str) -> tomlkit.items.Array: + """Convert a comma-separated string of authors into a tomlkit multiline array. + + Omits empty 'email' fields to prevent validation errors during 'uv run scons' + and PEP 621 metadata compliance checks. + + :param rawAuthorsText: The raw authors string (e.g., "Author Name , Another"). + :return: A tomlkit.items.Array object containing inline tables. + """ + authorsArray: tomlkit.items.Array = tomlkit.array() + authorsArray.multiline(True) + + authorParts: list[str] = [p.strip() for p in rawAuthorsText.split(",") if p.strip()] + + partItem: str + for partItem in authorParts: + regexMatch: re.Match[str] | None = re.match(r"^(.*?)\s*<(.*?)>$", partItem) + if regexMatch: + authorName: str = regexMatch.group(1).strip() + authorEmail: str = regexMatch.group(2).strip() + authorInlineTable: tomlkit.items.InlineTable = tomlkit.inline_table() + authorDataDict: dict[str, str] = {"name": authorName} + if authorEmail: + authorDataDict["email"] = authorEmail + authorInlineTable.update(authorDataDict) + authorsArray.append(authorInlineTable) + elif partItem: + authorInlineTable = tomlkit.inline_table() + authorInlineTable.update({"name": partItem}) + authorsArray.append(authorInlineTable) + + return authorsArray + + +def fixTomlIndentation(tomlContentText: str) -> str: + """Post-process serialized TOML string to enforce tab-based indentation. + + Converts space-based leading indents into hard tabs while preserving the exact + structural layout and line breaks produced by tomlkit. + + :param tomlContentText: The raw TOML string generated by tomlkit.dumps(). + :return: The TOML string with strictly enforced tab indentation. + """ + contentLines: list[str] = tomlContentText.splitlines(keepends=True) + fixedLines: list[str] = [] + + lineItem: str + for lineItem in contentLines: + if lineItem.startswith(" "): + lineItem = re.sub(r"^(?: {4})+", lambda matchObj: "\t" * (len(matchObj.group(0)) // 4), lineItem) + fixedLines.append(lineItem) + + outputText: str = "".join(fixedLines) + if not outputText.endswith("\n"): + outputText += "\n" + return outputText + + +def cleanupPlaceholderAuthors(projectSectionDict: dict[str, Any]) -> None: + """Remove NV Access placeholder entries from authors and maintainers fields in-place. + + :param projectSectionDict: The project table/dictionary within the TOML structure. + :return: None + """ + fieldName: str + for fieldName in ["authors", "maintainers"]: + if fieldName in projectSectionDict and isinstance(projectSectionDict[fieldName], (list, MutableSequence)): + authorList: Any = projectSectionDict[fieldName] + listIndex: int + for listIndex in range(len(authorList) - 1, -1, -1): + authorItem: Any = authorList[listIndex] + authorName: Any = authorItem.get("name", "") if hasattr(authorItem, "get") else "" + if not authorName and isinstance(authorItem, dict): + authorName = authorItem.get("name", "") + + if str(authorName).strip().lower() in ["nv access", "nvaccess"]: + authorList.pop(listIndex) + + +def getBasePackageName(dependencyStringText: str) -> str: + """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). + + :param dependencyStringText: The raw dependency string. + :return: The normalized base package name in lowercase. + """ + regexMatch: re.Match[str] | None = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", dependencyStringText.strip()) + basePackageName: str = regexMatch.group(0) if regexMatch else dependencyStringText.strip() + return basePackageName.lower().replace("_", "-") + + +def insertKeyAfter(table: Any, targetKey: str, newKey: str, value: Any) -> None: + """Insert or move a key in a TOML table immediately after a target key. + + :param table: The target TOML table or dictionary to modify. + :param targetKey: The key after which the new key will be inserted. + :param newKey: The key name to insert or reposition. + :param value: The value associated with the new key. + :return: None + """ + tableItems: list[tuple[Any, Any]] = list(table.items()) + table.clear() + + isInserted: bool = False + keyItem: Any + valueItem: Any + for keyItem, valueItem in tableItems: + if keyItem == newKey: + continue + + table[keyItem] = valueItem + + if keyItem == targetKey: + table[newKey] = value + isInserted = True + + if not isInserted: + table[newKey] = value diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 40c7593..78ad229 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -2,842 +2,18 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from collections.abc import MutableMapping, MutableSequence -import argparse -import ast -import logging -import os -import re -import shutil -import subprocess -import sys -import tempfile -from datetime import datetime -from pathlib import Path -from typing import Any, cast - -import tomlkit - -logger: logging.Logger = logging.getLogger("syncAddon") - - -def buildArgParser() -> argparse.ArgumentParser: - """Build and configure the command-line argument parser. - - :return: Configured ArgumentParser object. - """ - parser: argparse.ArgumentParser = 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]: - """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] = {} - keyNode: ast.expr | None - valNode: ast.expr - for keyNode, valNode in zip(node.keys, node.values): - if keyNode is None: - continue - key: Any = getattr(keyNode, "value", None) - if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": - valNode = valNode.args[0] - val: Any = 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] = {} - keyword: ast.keyword - for keyword in keywords: - key: str | None = keyword.arg - valNode: ast.expr = keyword.value - if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": - valNode = valNode.args[0] - val: Any = getattr(valNode, "value", None) - if key is not None: - extracted[key] = val - 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. - """ - child: ast.AST - 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. - - Omits empty 'email' fields to prevent validation errors during 'uv run scons' - and PEP 621 metadata compliance checks. - - :param rawAuthors: The raw authors string (e.g., "Author Name , Another"). - :return: A tomlkit.items.Array object containing inline tables. - """ - authorsList: tomlkit.items.Array = tomlkit.array() - authorsList.multiline(True) - - parts: list[str] = [p.strip() for p in rawAuthors.split(",") if p.strip()] - - part: str - for part in parts: - regexMatch: re.Match[str] | None = re.match(r"^(.*?)\s*<(.*?)>$", part) - if regexMatch: - name: str = regexMatch.group(1).strip() - email: str = regexMatch.group(2).strip() - authorTable: tomlkit.items.InlineTable = tomlkit.inline_table() - authorData: dict[str, str] = {"name": name} - # Omit empty email to prevent validation failures in tools like uv/scons - if email: - authorData["email"] = email - authorTable.update(authorData) - authorsList.append(authorTable) - elif part: - authorTable = tomlkit.inline_table() - # Omit email key entirely if no email address was provided - authorTable.update({"name": part}) - authorsList.append(authorTable) - - 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: list[str] = tomlText.splitlines(keepends=True) - fixedLines: list[str] = [] - inTargetArray: bool = False - - line: str - for line in lines: - stripped: str = 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.TOMLDocument = tomlkit.parse(f.read()) - - if "project" not in doc: - doc["project"] = tomlkit.table() - - projectSection: Any = 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 = 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. - - :param projectSection: The project table/dictionary within the TOML structure. - :return: None - """ - field: str - for field in ["authors", "maintainers"]: - if field in projectSection and isinstance(projectSection[field], (list, MutableSequence)): - tomlList: Any = projectSection[field] - i: int - for i in range(len(tomlList) - 1, -1, -1): - item: Any = tomlList[i] - name: Any = 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(dependencyString: str) -> str: - """Extract base package name robustly (handles !=, <=, ~=, @ URLs, markers, etc.). - - :param dependencyString: The raw dependency string. - :return: The normalized base package name in lowercase. - """ - regexMatch: re.Match[str] | None = re.match(r"^[A-Za-z0-9][A-Za-z0-9._-]*", dependencyString.strip()) - base: str = regexMatch.group(0) if regexMatch else dependencyString.strip() - return base.lower().replace("_", "-") - - -def replaceAstRange(templateLines: list[str], replacements: dict[tuple[int, int], str]) -> None: - """Apply AST line replacements on a line-by-line list in reverse order. - - :param templateLines: 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: list[tuple[int, int]] = sorted(replacements.keys(), key=lambda x: x[0], reverse=True) - start: int - end: int - for start, end in sortedRanges: - templateLines[start:end] = [replacements[(start, end)]] - - -def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: - """Intelligently merge two dependency lists by updating package versions based on base names. - - Preserves custom user dependencies while replacing existing template tools with their newer versions. - - :param projList: The existing project's dependency list. - :param tplList: The template's dependency list. - :return: A merged list with updated versions and preserved custom items. - """ - projIndexByBase: dict[str, int] = {} - idx: int - item: Any - for idx, item in enumerate(projList): - if isinstance(item, str): - base: str = getBasePackageName(item) - projIndexByBase[base] = idx - - merged: list[Any] = list(projList) - - tplItem: Any - for tplItem in tplList: - if isinstance(tplItem, str): - tplBase: str = getBasePackageName(tplItem) - if tplBase in projIndexByBase: - targetIdx: int = projIndexByBase[tplBase] - merged[targetIdx] = tplItem - else: - merged.append(tplItem) - else: - if tplItem not in merged: - merged.append(tplItem) - - return merged - - -def deepMergeDicts(dictProj: dict[str, Any], dictTpl: dict[str, Any]) -> dict[str, Any]: - """Recursively merges dictTpl into dictProj. - - :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. - """ - key: str - value: Any - for key, value in dictTpl.items(): - if key in dictProj: - projVal: Any = dictProj[key] - if isinstance(projVal, MutableMapping) and isinstance(value, MutableMapping): - deepMergeDicts(projVal, value) - elif isinstance(projVal, MutableSequence) and isinstance(value, MutableSequence): - dictProj[key] = mergeDependencyLists(list(projVal), list(value)) - else: - pass - else: - dictProj[key] = value - return dictProj - - -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 mapping var_name -> (ast_node, unparsed_expr). - """ - filePathObj: Path = Path(filePath) - if not filePathObj.exists(): - return {}, {} - - with filePathObj.open("r", encoding="utf-8") as f: - try: - tree: ast.AST = ast.parse(f.read()) - except SyntaxError as syntaxErr: - logger.error("Syntax error while reading %s: %s", filePathObj, syntaxErr) - return {}, {} - - metadata: dict[str, Any] = {} - globalVars: dict[str, tuple[ast.AST, str]] = {} - topLevelVars: set[str] = { - "pythonSources", - "excludedFiles", - "baseLanguage", - "markdownExtensions", - "brailleTables", - "symbolDictionaries", - "speechDictionaries", - } - - node: ast.AST - for node in ast.walk(tree): - if isinstance(node, ast.Assign) and len(node.targets) == 1: - target: ast.expr = node.targets[0] - if not isinstance(target, ast.Name): - continue - varName: str = target.id - - if varName == "addon_info": - if isinstance(node.value, ast.Dict): - metadata.update(parseAstDict(node.value)) - 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] = (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] = (node.value, ast.unparse(node.value)) - - return metadata, globalVars - - -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. - """ - templatePathObj: Path = Path(tplPath) - projectPathObj: Path = Path(projPath) +"""CLI entry point for the NVDA Add-on Template synchronization tool.""" - if not templatePathObj.exists(): - return "skipped (no template found)" - - if not projectPathObj.exists(): - try: - projData: tomlkit.TOMLDocument = createPyprojectFromTemplate(templatePathObj, metadata) - if not dryRun: - tomlOutput: str = fixTomlIndentation(tomlkit.dumps(projData)) - projectPathObj.parent.mkdir(parents=True, exist_ok=True) - with projectPathObj.open("w", encoding="utf-8") as f: - f.write(tomlOutput) - logger.info("Created missing pyproject.toml at %s", projectPathObj) - return "created from template" - except Exception as exceptionObj: - logger.error("Failed to create pyproject.toml: %s", exceptionObj) - return f"failed to create from template ({str(exceptionObj)})" - - try: - with projectPathObj.open("r", encoding="utf-8") as f: - projData = tomlkit.parse(f.read()) - with templatePathObj.open("r", encoding="utf-8") as f: - tplData: tomlkit.TOMLDocument = tomlkit.parse(f.read()) - - wasOriginallyNvaccess: bool = False - if "project" in projData: - field: str - for field in ["authors", "maintainers"]: - if field in projData["project"] and isinstance(projData["project"][field], (list, MutableSequence)): - item: Any - for item in projData["project"][field]: - name: Any = 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"]: - wasOriginallyNvaccess = True - break - - projDeps: list[Any] = [] - if "project" in projData and "dependencies" in projData["project"]: - projDeps = list(projData["project"]["dependencies"]) - del projData["project"]["dependencies"] - - mergedData: dict[str, Any] = deepMergeDicts(cast(dict[str, Any], projData), cast(dict[str, Any], tplData)) - - if "project" in mergedData: - projectSection: dict[str, Any] = mergedData["project"] - - if not wasOriginallyNvaccess: - cleanupPlaceholderAuthors(projectSection) - - if isinstance(projectSection.get("dependencies"), (list, MutableSequence)): - tplDeps: list[Any] = projectSection["dependencies"] - tplBases: set[str] = {getBasePackageName(d) for d in tplDeps} - - groupBases: set[str] = set() - if "dependency-groups" in mergedData and isinstance(mergedData["dependency-groups"], MutableMapping): - grp: Any - for grp in mergedData["dependency-groups"].values(): - if isinstance(grp, (list, MutableSequence)): - grpItem: Any - for grpItem in grp: - if isinstance(grpItem, str): - groupBases.add(getBasePackageName(grpItem)) - - legacyToolingBases: set[str] = { - "pre-commit", - "scons", - "markdown", - "nh3", - "crowdin-api-client", - "lxml", - "mdx-truly-sane-lists", - "markdown-link-attr-modifier", - "mdx-gh-links", - "uv", - "ruff", - "prek", - "pyright", - } - - dep: Any - for dep in projDeps: - base: str = getBasePackageName(dep) - isInTemplate: bool = base in tplBases or base in groupBases - isDroppedTooling: bool = base in legacyToolingBases and not isInTemplate - - if not isInTemplate and not isDroppedTooling: - tplDeps.append(dep) - - if not dryRun: - tomlOutput = fixTomlIndentation(tomlkit.dumps(cast(tomlkit.TOMLDocument, mergedData))) - with projectPathObj.open("w", encoding="utf-8") as f: - f.write(tomlOutput) - return "merged intelligently (tomlkit)" - except Exception as exceptionObj: - logger.error("Failed to merge pyproject.toml: %s", exceptionObj) - return f"failed to merge ({str(exceptionObj)})" - - -def mergeBuildvarsFile( - projPath: str | Path, - tplPath: str | Path, - metadata: 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. - - :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 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. - """ - templatePathObj: Path = Path(tplPath) - projectPathObj: Path = Path(projPath) - - if not templatePathObj.exists(): - return "failed (no template found)" - - with templatePathObj.open("r", encoding="utf-8") as f: - tplContent: str = f.read() - - try: - tree: ast.AST = ast.parse(tplContent) - except SyntaxError as syntaxErr: - return f"failed (template syntax error: {syntaxErr})" - - tplLines: list[str] = tplContent.splitlines(keepends=True) - replacements: dict[tuple[int, int], str] = {} - requiresOsImport: bool = False - - node: ast.AST - for node in ast.walk(tree): - if isinstance(node, ast.Call) and getattr(node.func, "id", None) == "AddonInfo": - kw: ast.keyword - for kw in node.keywords: - if kw.arg in metadata: - key: str = kw.arg - val: Any = metadata[key] - formattedVal: str - if val is None: - formattedVal = "None" - elif isinstance(val, str): - isTranslatable: bool = 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: - lineContent: str = tplLines[kw.lineno - 1] - indent: str = 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: - target: ast.expr = node.targets[0] - if isinstance(target, ast.Name) and target.id in globalVars: - key = target.id - valNode: ast.AST - valExpression: str - 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())] - replacements[(node.lineno - 1, node.end_lineno)] = ( - 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 - 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: str = ast.unparse(node.annotation) - replacements[(node.lineno - 1, node.end_lineno)] = ( - f"{indent}{key}: {typeStr} = {valExpression}\n" - ) - - replaceAstRange(tplLines, replacements) - - if requiresOsImport: - hasOsImport: bool = any("import os" in line for line in tplLines[:15]) - if not hasOsImport: - tplLines.insert(0, "import os\n") - - if not dryRun: - with projectPathObj.open("w", encoding="utf-8") as f: - f.writelines(tplLines) - return "merged & structured (AST verified)" - - -def setupAddonMergeIgnore(tempDir: str | Path, addonDir: str | Path, dryRun: bool = False) -> None: - """Ensures .addonmergeignore exists in the target add-on directory. - - If missing, copies it from the template to bootstrap default ignore rules. - Does nothing if the file is already present. - - :param tempDir: Path to the template directory. - :param addonDir: Path to the target add-on directory. - :param dryRun: If True, simulate execution without modifying files. - :return: None - """ - ignoreFilePath: Path = Path(addonDir) / ".addonmergeignore" - templateIgnorePath: Path = Path(tempDir) / ".addonmergeignore" - - if not ignoreFilePath.exists() and templateIgnorePath.exists(): - if not dryRun: - shutil.copy2(templateIgnorePath, ignoreFilePath) - logger.info("Bootstrapped missing .addonmergeignore from template.") - - -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. - :return: None - """ - logger.info("Synchronizing template machinery files...") - # Bootstrap missing .addonmergeignore before reading protection rules - setupAddonMergeIgnore(tempDir, addonDir, dryRun) - - protectedElements: set[str] = { - "readme.md", - "changelog.md", - "addontemplate.egg-info", - ".github/workflows/unittests.yml", - "addon", - ".git", - "__pycache__", - ".venv", - "docs", - ".ruff_cache", - "tests", - } - - ignoreFilePath: str = os.path.join(addonDir, ".addonmergeignore") - if os.path.exists(ignoreFilePath): - logger.info("Reading local custom exclusions from .addonmergeignore...") - try: - with open(ignoreFilePath, "r", encoding="utf-8") as f: - line: str - for line in f: - cleanLine: str = line.strip().replace("\\", "/").lower() - if cleanLine and not cleanLine.startswith("#"): - protectedElements.add(cleanLine) - except Exception as exceptionObj: - logger.warning("Failed to parse .addonmergeignore (%s)", exceptionObj) - - syncReport: list[str] = [] - - def addReportEntry(entry: str) -> None: - """Add an entry to syncReport ensuring no duplicates exist. - - :param entry: The status report line to record. - :return: None - """ - if entry not in syncReport: - syncReport.append(entry) - - def inspectAndCopyDirectory(srcDir: str, dstDir: str) -> None: - """Inspect directory recursively for protected elements and copy non-protected files. - - :param srcDir: Path to the source directory to inspect. - :param dstDir: Path to the destination target directory. - :return: None - """ - root: str - dirs: list[str] - files: list[str] - for root, dirs, files in os.walk(srcDir): - relDir: str = os.path.relpath(root, tempDir) - - dirsToCopy: list[str] = [] - dirName: str - for dirName in dirs: - relPath: str = dirName if relDir == "." else os.path.join(relDir, dirName) - relPathNormalized: str = relPath.replace("\\", "/").lower() - if relPathNormalized in protectedElements: - displayPath: str = relPath.replace("\\", "/") - addReportEntry(f"- **{displayPath}/**: skipped (protected scope)") - else: - dirsToCopy.append(dirName) - dirs[:] = dirsToCopy - - relDst: str = os.path.relpath(root, srcDir) - targetRoot: str = os.path.join(dstDir, relDst) - - fileName: str - for fileName in files: - relPath = fileName if relDir == "." else os.path.join(relDir, fileName) - relPathNormalized = relPath.replace("\\", "/").lower() - if relPathNormalized in protectedElements: - displayPath = relPath.replace("\\", "/") - addReportEntry(f"- **{displayPath}**: skipped (protected scope)") - else: - if not dryRun: - srcFile: str = os.path.join(root, fileName) - dstFile: str = os.path.join(targetRoot, fileName) - os.makedirs(os.path.dirname(dstFile), exist_ok=True) - shutil.copy2(srcFile, dstFile) - - item: str - for item in os.listdir(tempDir): - itemNormalized: str = item.lower() - if itemNormalized in protectedElements: - addReportEntry(f"- **{item}**: skipped (protected scope)") - continue - - if item in ["buildVars.py", "pyproject.toml"]: - continue - - srcItem: str = os.path.join(tempDir, item) - dstItem: str = os.path.join(addonDir, item) - - try: - if os.path.isdir(srcItem): - inspectAndCopyDirectory(srcItem, dstItem) - addReportEntry(f"- **{item}/**: merged safely") - else: - if not dryRun: - shutil.copy2(srcItem, dstItem) - addReportEntry(f"- **{item}**: synchronized") - except Exception as exceptionObj: - addReportEntry(f"- **{item}**: failed ({str(exceptionObj)})") - - logger.info("Processing structural configuration merges...") - templateBuildvars: str = os.path.join(tempDir, "buildVars.py") - templatePyproject: str = os.path.join(tempDir, "pyproject.toml") - - oldBuildvars: str = os.path.join(addonDir, "buildVars.py") - oldPyproject: str = os.path.join(addonDir, "pyproject.toml") - - buildvarsMetadata: dict[str, Any] - buildvarsGlobals: dict[str, tuple[ast.AST, str]] - buildvarsMetadata, buildvarsGlobals = extractBuildvarsMetadata(oldBuildvars) - addonName: Any = buildvarsMetadata.get("addon_name", os.path.basename(addonDir)) - - buildvarsStatus: str = mergeBuildvarsFile(oldBuildvars, templateBuildvars, buildvarsMetadata, buildvarsGlobals, dryRun) - pyprojectStatus: str = mergePyprojectToml(oldPyproject, templatePyproject, buildvarsMetadata, dryRun) - - logger.info("=" * 50) - logger.info("UPDATE REPORT") - logger.info("=" * 50) - logger.info("Add-on: %s", addonName) - logger.info("\nTemplate synchronization:") - entry: str - for entry in sorted(syncReport): - logger.info(" %s", entry) - logger.info( - "\nConfiguration files:\n - **buildVars.py**: %s\n - **pyproject.toml**: %s", - buildvarsStatus, - pyprojectStatus, - ) - - -def main() -> None: - """Execute main CLI entry point for the NVDA Add-on update tool. - - :return: None - """ - logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") - - parser: argparse.ArgumentParser = buildArgParser() - args: argparse.Namespace = parser.parse_args() - - addonDirInput: str | None = args.addonDir - addonDir: str - if addonDirInput: - addonDir = os.path.abspath(addonDirInput) - else: - cwd: Path = Path(os.getcwd()).resolve() - addonRoot: Path | None = 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) - - logger.info("=== NVDA ADD-ON UPDATE TOOL ===") - logger.info("Target Directory: %s", addonDir) - - oldBuildvars: str = os.path.join(addonDir, "buildVars.py") - - if not os.path.exists(oldBuildvars): - 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) - - logger.info("Phase 1: Analyzing existing project structure and metadata...") - buildvarsMetadata: dict[str, Any] - buildvarsMetadata, _ = extractBuildvarsMetadata(oldBuildvars) - addonName: Any = buildvarsMetadata.get("addon_name", os.path.basename(addonDir)) - logger.info("Target Add-on Identified: %s", addonName) - - if args.dryRun: - logger.info("RUNNING IN SIMULATION MODE (--dry-run). No files will be modified.") - - logger.info("Phase 2: Safety backup verification...") - if args.dryRun: - logger.info("Safety backup skipped (simulation mode active).") - elif args.skipBackup: - logger.info("Safety backup skipped (--skip-backup requested by user).") - else: - backupDir: str = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" - 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_*"), - ) - logger.info("Backup created successfully.") - except Exception as exceptionObj: - logger.error("Critical: Backup failed (%s). Aborting update.", exceptionObj) - if sys.stdin.isatty(): - input("\nPress Enter to exit...") - sys.exit(1) - - if args.templateDir: - templatePath: str = os.path.abspath(args.templateDir) - logger.info("Phase 3: Using local template directory: %s", templatePath) - if not os.path.exists(os.path.join(templatePath, "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: - logger.info("Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") - with tempfile.TemporaryDirectory() as tempDir: - logger.info("Cloning template into temporary workspace...") - templateUrl: str = "https://github.com/nvaccess/AddonTemplate.git" - - try: - subprocess.run( - ["git", "clone", "--depth", "1", templateUrl, tempDir], - check=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - ) - logger.info("Template cloned successfully.") - except (subprocess.CalledProcessError, FileNotFoundError) as exceptionObj: - logger.error("Failed to execute git clone. Make sure Git is available in your PATH.") - if isinstance(exceptionObj, subprocess.CalledProcessError) and exceptionObj.stderr: - logger.error("Details: %s", exceptionObj.stderr.decode("utf-8", errors="ignore")) - if sys.stdin.isatty(): - input("\nPress Enter to exit...") - sys.exit(1) - - runSynchronization(tempDir, addonDir, args.dryRun) +from pathlib import Path +import sys - if not args.dryRun: - logger.info("Project successfully updated. Workspace cleared.") - else: - logger.info("Simulation finished. Workspace cleared.") +# Insert the script's parent directory at the beginning of sys.path. +# This ensures syncAddonTool package resolution regardless of the current working directory. +SCRIPT_DIR: Path = Path(__file__).resolve().parent +if str(SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(SCRIPT_DIR)) +from syncAddonTool.cli import main if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/tests/unit/template/fixtures/legacyBuildVars.py b/tests/unit/template/fixtures/legacyBuildVars.py new file mode 100644 index 0000000..10580ae --- /dev/null +++ b/tests/unit/template/fixtures/legacyBuildVars.py @@ -0,0 +1,11 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Fixture file representing a legacy dictionary-based buildVars.py configuration.""" + +addon_info = { + "addon_name": "myAddon", + "addon_summary": "My Test Addon", + "addon_version": "1.0.0", +} diff --git a/tests/unit/template/fixtures/modernBuildVars.py b/tests/unit/template/fixtures/modernBuildVars.py new file mode 100644 index 0000000..f45a497 --- /dev/null +++ b/tests/unit/template/fixtures/modernBuildVars.py @@ -0,0 +1,16 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Fixture file representing a modern buildVars.py without optional imports like SpeechDictionaries.""" + +from site_scons.site_tools.NVDATool.typings import ( + AddonInfo, + BrailleTables, + SymbolDictionaries, +) + +addon_info = AddonInfo( + addon_name="myAddon", + addon_summary="My Test Addon", +) diff --git a/tests/unit/template/fixtures/templateBuildVars.py b/tests/unit/template/fixtures/templateBuildVars.py new file mode 100644 index 0000000..f7444e8 --- /dev/null +++ b/tests/unit/template/fixtures/templateBuildVars.py @@ -0,0 +1,18 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Fixture file representing official AddonTemplate buildVars.py using AddonInfo class.""" + +from site_scons.site_tools.NVDATool.typings import ( + AddonInfo, + BrailleTables, + SpeechDictionaries, + SymbolDictionaries, +) + +addon_info = AddonInfo( + addon_name="myAddon", + addon_summary="My Test Addon", + addon_version="1.0.0", +) diff --git a/tests/unit/template/fixtures/templatePyproject.toml b/tests/unit/template/fixtures/templatePyproject.toml new file mode 100644 index 0000000..f5ba453 --- /dev/null +++ b/tests/unit/template/fixtures/templatePyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +version = "0.1.0" +description = "NVDA Addon Template" +dependencies = [ + "pyright>=1.1.407", + "ruff>=0.2.0", +] + +[dependency-groups] +dev = [ + "pytest>=7.0.0", +] \ No newline at end of file diff --git a/tests/unit/template/fixtures/userPyproject.toml b/tests/unit/template/fixtures/userPyproject.toml new file mode 100644 index 0000000..e57632a --- /dev/null +++ b/tests/unit/template/fixtures/userPyproject.toml @@ -0,0 +1,18 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "myAddon" +version = "1.0.0" +description = "My test addon" +dependencies = [ + "pyright>=1.1.411", + "ruff>=0.1.0", + "requests>=2.28.0", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", +] \ No newline at end of file diff --git a/tests/unit/template/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonWithTemplate.py index 3a2497a..eb99d38 100644 --- a/tests/unit/template/test_syncAddonWithTemplate.py +++ b/tests/unit/template/test_syncAddonWithTemplate.py @@ -2,24 +2,24 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""Unit test suite for syncAddonWithTemplate.py module.""" +"""Unit test suite for syncAddonTool package.""" import tempfile import unittest from pathlib import Path -# Import functions to test -from syncAddonWithTemplate import ( - extractBuildvarsMetadata, +# Import functions from their exact module location within syncAddonTool +from syncAddonTool.buildVarsSync import extractBuildvarsMetadata, mergeBuildvarsFile +from syncAddonTool.engine import runSynchronization, setupAddonMergeIgnore +from syncAddonTool.pyproject import ( fixTomlIndentation, formatAuthorList, - mergeBuildvarsFile, mergeDependencyLists, mergePyprojectToml, - setupAddonMergeIgnore, - runSynchronization, ) +FIXTURES_DIR: Path = Path(__file__).parent / "fixtures" + def load_tests( loader: unittest.TestLoader, tests: unittest.TestSuite, pattern: str | None @@ -28,9 +28,9 @@ def load_tests( Enforces test execution in source code definition order using class dict insertion order. """ - # Python's dir() sorts methods alphabetically by default. We use __dict__ - # to preserve the exact declaration order from the source file. - orderIndex = {name: i for i, name in enumerate(TestSyncAddonWithTemplate.__dict__)} + orderIndex: dict[str, int] = { + name: i for i, name in enumerate(TestSyncAddonWithTemplate.__dict__) + } loader.sortTestMethodsUsing = ( lambda a, b: orderIndex.get(a, 999) - orderIndex.get(b, 999) ) @@ -43,131 +43,61 @@ class TestSyncAddonWithTemplate(unittest.TestCase): def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: """Ensure legacy buildVars.py is correctly merged into the latest official template structure.""" with tempfile.TemporaryDirectory() as tempDir: - projBvPath = Path(tempDir) / "buildVars.py" - tplBvPath = Path(tempDir) / "template_buildVars.py" + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" - # 1. Legacy dictionary-based buildVars.py - projBvPath.write_text( - 'addon_info = {\n' - ' "addon_name": "dayOfTheWeek",\n' - ' "addon_summary": _("Day of the week"),\n' - ' "addon_version": "20251022.0.1",\n' - '}\n' - 'import os\n' - 'pythonSources = [os.path.join("addon", "globalPlugins", "*.py")]\n' - 'i18nSources = pythonSources + ["buildVars.py"]\n' - 'excludedFiles = []\n' - 'baseLanguage = "en"\n' - 'markdownExtensions = []\n', - encoding="utf-8", - ) + # 1. Legacy buildVars fixture + legacyFixture: Path = FIXTURES_DIR / "legacyBuildVars.py" + projBvPath.write_text(legacyFixture.read_text(encoding="utf-8"), encoding="utf-8") - # 2. Official template buildVars.py content - tplBvPath.write_text( - 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' - 'from site_scons.site_tools.NVDATool.utils import _\n\n' - 'addon_info = AddonInfo(\n' - ' addon_name="addonTemplate",\n' - ' addon_summary=_("Add-on user visible name"),\n' - ' addon_description=_("""Description."""),\n' - ' addon_version="x.y",\n' - ' addon_changelog=_("""Changelog."""),\n' - ' addon_author="name ",\n' - ' addon_url=None,\n' - ' addon_sourceURL=None,\n' - ' addon_docFileName="readme.html",\n' - ' addon_minimumNVDAVersion=None,\n' - ' addon_lastTestedNVDAVersion=None,\n' - ' addon_updateChannel=None,\n' - ' addon_license=None,\n' - ' addon_licenseURL=None,\n' - ')\n\n' - 'pythonSources: list[str] = []\n' - 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' - 'excludedFiles: list[str] = []\n' - 'baseLanguage: str = "en"\n' - 'markdownExtensions: list[str] = []\n' - 'brailleTables: BrailleTables = {}\n' - 'symbolDictionaries: SymbolDictionaries = {}\n' - 'speechDictionaries: SpeechDictionaries = {}\n', - encoding="utf-8", - ) + # 2. Official template buildVars fixture + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + metadata: dict + globalVars: dict metadata, globalVars = extractBuildvarsMetadata(projBvPath) - status = mergeBuildvarsFile( + status: str = mergeBuildvarsFile( projBvPath, tplBvPath, metadata, globalVars, dryRun=False ) self.assertEqual(status, "merged & structured (AST verified)") - content = projBvPath.read_text(encoding="utf-8") - # Verify legacy metadata mapping (handling single quote formatting) - self.assertIn("addon_name='dayOfTheWeek'", content) - self.assertIn("addon_version='20251022.0.1'", content) - # Verify new official template imports and variables - self.assertIn("from site_scons.site_tools.NVDATool.utils import _", content) - self.assertIn("brailleTables: BrailleTables = {}", content) - self.assertIn("symbolDictionaries: SymbolDictionaries = {}", content) - self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + content: str = projBvPath.read_text(encoding="utf-8") + # Verify metadata mapping from fixture + self.assertIn("addon_name='myAddon'", content) + self.assertIn("addon_version='1.0.0'", content) + # Verify new official template imports + self.assertIn("from site_scons.site_tools.NVDATool.typings import", content) def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: - """Ensure modern buildVars.py gets missing speechDictionaries injected from official template.""" + """Ensure modern buildVars.py gets missing speechDictionaries imported/injected from official template.""" with tempfile.TemporaryDirectory() as tempDir: - projBvPath = Path(tempDir) / "buildVars.py" - tplBvPath = Path(tempDir) / "template_buildVars.py" + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" - # 1. Modern buildVars.py without speechDictionaries - projBvPath.write_text( - 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries\n' - 'from site_scons.site_tools.NVDATool.utils import _\n\n' - 'addon_info = AddonInfo(\n' - ' addon_name="dayOfTheWeek",\n' - ' addon_summary=_("Day of the week"),\n' - ' addon_version="20260222.0.0",\n' - ')\n\n' - 'import os\n' - 'pythonSources: list[str] = [os.path.join("addon", "globalPlugins", "*.py")]\n' - 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' - 'excludedFiles: list[str] = []\n' - 'baseLanguage: str = "en"\n' - 'markdownExtensions: list[str] = []\n' - 'brailleTables: BrailleTables = {}\n' - 'symbolDictionaries: SymbolDictionaries = {}\n', - encoding="utf-8", - ) + # 1. Modern buildVars fixture (without SpeechDictionaries imported in original) + modernFixture: Path = FIXTURES_DIR / "modernBuildVars.py" + projBvPath.write_text(modernFixture.read_text(encoding="utf-8"), encoding="utf-8") - # 2. Official template buildVars.py - tplBvPath.write_text( - 'from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries\n' - 'from site_scons.site_tools.NVDATool.utils import _\n\n' - 'addon_info = AddonInfo(\n' - ' addon_name="addonTemplate",\n' - ' addon_summary=_("Add-on user visible name"),\n' - ' addon_version="x.y",\n' - ')\n\n' - 'pythonSources: list[str] = []\n' - 'i18nSources: list[str] = pythonSources + ["buildVars.py"]\n' - 'excludedFiles: list[str] = []\n' - 'baseLanguage: str = "en"\n' - 'markdownExtensions: list[str] = []\n' - 'brailleTables: BrailleTables = {}\n' - 'symbolDictionaries: SymbolDictionaries = {}\n' - 'speechDictionaries: SpeechDictionaries = {}\n', - encoding="utf-8", - ) + # 2. Official template buildVars fixture + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + metadata: dict + globalVars: dict metadata, globalVars = extractBuildvarsMetadata(projBvPath) - status = mergeBuildvarsFile( + status: str = mergeBuildvarsFile( projBvPath, tplBvPath, metadata, globalVars, dryRun=False ) self.assertEqual(status, "merged & structured (AST verified)") - content = projBvPath.read_text(encoding="utf-8") - self.assertIn("addon_name='dayOfTheWeek'", content) - self.assertIn("speechDictionaries: SpeechDictionaries = {}", content) + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name='myAddon'", content) + self.assertIn("SpeechDictionaries", content) - def test_setupAddonMergeIgnore(self) -> None: + def testSetupAddonMergeIgnore(self) -> None: """Verify bootstrapping of .addonmergeignore from template to add-on directory. Tests creation when missing, preservation when existing, and behavior in dry-run mode. @@ -201,7 +131,7 @@ def test_setupAddonMergeIgnore(self) -> None: setupAddonMergeIgnore(tempDir=templateDir, addonDir=addonDir, dryRun=False) self.assertEqual(addonIgnore.read_text(encoding="utf-8"), "customRule/\n") - def test_addonMergeIgnore(self) -> None: + def testAddonMergeIgnore(self) -> None: """Verify that files specified in .addonmergeignore are excluded during synchronization. Ensures that existing files listed in the ignore file retain their original content @@ -231,10 +161,10 @@ def test_addonMergeIgnore(self) -> None: ignoreFile: Path = addonDir / ".addonmergeignore" ignoreFile.write_text("ignoredFile.txt\n", encoding="utf-8") - # 4. Execute synchronization with correct arguments (tempDir, addonDir, dryRun) + # 4. Execute synchronization with correct arguments runSynchronization( - tempDir=str(templateDir), - addonDir=str(addonDir), + tempDir=templateDir, + addonDir=addonDir, dryRun=False, ) @@ -252,8 +182,8 @@ def test_addonMergeIgnore(self) -> None: def testMergeBuildvarsAutoImportsOs(self) -> None: """Ensure 'import os' is automatically added if merged buildVars uses the os module.""" with tempfile.TemporaryDirectory() as tempDir: - projBvPath = Path(tempDir) / "buildVars.py" - tplBvPath = Path(tempDir) / "template_buildVars.py" + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" # Legacy buildVars using os module without import in template projBvPath.write_text( @@ -267,15 +197,17 @@ def testMergeBuildvarsAutoImportsOs(self) -> None: encoding="utf-8", ) + metadata: dict + globalVars: dict metadata, globalVars = extractBuildvarsMetadata(projBvPath) mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) - content = projBvPath.read_text(encoding="utf-8") + content: str = projBvPath.read_text(encoding="utf-8") self.assertTrue(content.startswith("import os\n")) def testFixTomlIndentation(self) -> None: - """Ensure that 4 spaces are replaced by a tab inside maintainers/authors blocks only.""" - inputToml = ( + """Ensure 4-space indentations are converted to tabs across TOML blocks.""" + inputToml: str = ( 'name = "myAddon"\n' "maintainers = [\n" ' {name = "John Doe", email = "john@example.com"},\n' @@ -284,23 +216,23 @@ def testFixTomlIndentation(self) -> None: ' key = "value"\n' "}\n" ) - expectedOutput = ( + expectedOutput: str = ( 'name = "myAddon"\n' "maintainers = [\n" '\t{name = "John Doe", email = "john@example.com"},\n' "]\n" "otherSection = {\n" - ' key = "value"\n' + '\tkey = "value"\n' "}\n" ) - result = fixTomlIndentation(inputToml) + result: str = fixTomlIndentation(inputToml) self.assertEqual(result, expectedOutput) def testFormatAuthorList(self) -> None: """Ensure raw author string parsing produces a formatted tomlkit array.""" - rawAuthors = "John Doe , Jane Smith" - authorsArray = formatAuthorList(rawAuthors) + rawAuthors: str = "John Doe , Jane Smith" + authorsArray: list = formatAuthorList(rawAuthors) self.assertEqual(len(authorsArray), 2) self.assertEqual(authorsArray[0]["name"], "John Doe") @@ -311,10 +243,10 @@ def testFormatAuthorList(self) -> None: def testMergeDependencyLists(self) -> None: """Ensure dependency lists merge updates existing package versions while preserving custom ones.""" - projDeps = ["pyright>=1.1.0", "requests>=2.28.0", "ruff==0.1.0"] - tplDeps = ["pyright>=1.2.0", "ruff==0.2.0", "pytest"] + projDeps: list[str] = ["pyright>=1.1.0", "requests>=2.28.0", "ruff==0.1.0"] + tplDeps: list[str] = ["pyright>=1.2.0", "ruff==0.2.0", "pytest"] - merged = mergeDependencyLists(projDeps, tplDeps) + merged: list[str] = mergeDependencyLists(projDeps, tplDeps) # Check that versions from template override project versions self.assertIn("pyright>=1.2.0", merged) @@ -330,8 +262,8 @@ def testMergeDependencyLists(self) -> None: def testMergePyprojectTomlIntelligent(self) -> None: """Ensure pyproject.toml is intelligently merged without duplicating dependencies.""" with tempfile.TemporaryDirectory() as tempDir: - projToml = Path(tempDir) / "pyproject.toml" - tplToml = Path(tempDir) / "template_pyproject.toml" + projToml: Path = Path(tempDir) / "pyproject.toml" + tplToml: Path = Path(tempDir) / "template_pyproject.toml" projToml.write_text( '[project]\n' @@ -349,13 +281,77 @@ def testMergePyprojectTomlIntelligent(self) -> None: encoding="utf-8", ) - status = mergePyprojectToml(projToml, tplToml, metadata={}, dryRun=False) + status: str = mergePyprojectToml( + projToml, tplToml, metadataDict={}, dryRun=False + ) self.assertEqual(status, "merged intelligently (tomlkit)") - content = projToml.read_text(encoding="utf-8") + content: str = projToml.read_text(encoding="utf-8") self.assertIn('name = "myAddon"', content) self.assertIn('requests>=2.0.0', content) + def testMergePyprojectTomlPreservesHigherUserVersions(self) -> None: + """Ensure user dependencies with higher versions than template are preserved during merge.""" + with tempfile.TemporaryDirectory() as tempDir: + projToml: Path = Path(tempDir) / "pyproject.toml" + tplToml: Path = Path(tempDir) / "template_pyproject.toml" + + userFixture: Path = FIXTURES_DIR / "userPyproject.toml" + projToml.write_text(userFixture.read_text(encoding="utf-8"), encoding="utf-8") + + templateFixture: Path = FIXTURES_DIR / "templatePyproject.toml" + tplToml.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + status: str = mergePyprojectToml( + projToml, tplToml, metadataDict={}, dryRun=False + ) + self.assertEqual(status, "merged intelligently (tomlkit)") + + content: str = projToml.read_text(encoding="utf-8") + + # Verify that higher user version 1.1.411 is retained over template version 1.1.407 + self.assertIn( + "1.1.411", + content, + "Higher user version 1.1.411 was not preserved in pyproject.toml", + ) + self.assertNotIn( + "1.1.407", + content, + "Lower template version 1.1.407 should have been overridden", + ) + + def testMergePyprojectTomlPreservesHigherTemplateVersions(self) -> None: + """Ensure template dependencies with higher versions than user are adopted during merge.""" + with tempfile.TemporaryDirectory() as tempDir: + projToml: Path = Path(tempDir) / "pyproject.toml" + tplToml: Path = Path(tempDir) / "template_pyproject.toml" + + userFixture: Path = FIXTURES_DIR / "userPyproject.toml" + projToml.write_text(userFixture.read_text(encoding="utf-8"), encoding="utf-8") + + templateFixture: Path = FIXTURES_DIR / "templatePyproject.toml" + tplToml.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + status: str = mergePyprojectToml( + projToml, tplToml, metadataDict={}, dryRun=False + ) + self.assertEqual(status, "merged intelligently (tomlkit)") + + content: str = projToml.read_text(encoding="utf-8") + + # Verify that higher template version 0.2.0 (ruff) overrides lower user version 0.1.0 + self.assertIn( + "0.2.0", + content, + "Higher template version 0.2.0 was not adopted in pyproject.toml", + ) + self.assertNotIn( + "0.1.0", + content, + "Lower user version 0.1.0 should have been overridden", + ) + if __name__ == "__main__": unittest.main() From e073bd9352143eab1932aba20cb6825470742db2 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 9 Aug 2026 16:36:26 +0200 Subject: [PATCH 12/52] docs(managementFromGit): update documentation for syncAddonTool and standalone executable - Update docs/managementFromGit/updatingExistingAddon.md to document the syncAddonTool directory. - Add instructions for building and running the standalone executable using PyInstaller and `uv run --with pyinstaller`. --- .../updatingExistingAddons.md | 193 +++++++++++------- syncAddonTool/cli.py | 2 +- 2 files changed, 118 insertions(+), 77 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 1584b69..64fb589 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -68,44 +68,43 @@ 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. - +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`. +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). - +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`. - +Git must be installed and available in your system `PATH`. * For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: - Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). +Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). + +> [!IMPORTANT] +> **Project Structure & Remote Execution:** +> The update engine is structured as a Python package. The entry script `syncAddonWithTemplate.py` relies on the adjacent `syncAddonTool/` package directory. +> If you choose to copy or run the Python script from an external directory outside of the repository, **you must copy both `syncAddonWithTemplate.py` AND the `syncAddonTool/` directory together** into that external location. Alternatively, you can use the standalone executable (`syncAddonTool.exe`), which requires no external folders or Python dependencies. ### Running the automated tool The script is highly flexible and supports two execution modes: 1. **Standard Mode (No arguments):** - Run the script directly from the root of your repository or from any of its subdirectories. - It will automatically locate the project root by searching for `buildVars.py`. - - ```sh - uv run python syncAddonWithTemplate.py - ``` - +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 +``` 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 - ``` +Run the script from any working directory by supplying the optional `addonDir` path (relative or absolute) pointing to the add-on repository you wish to update. +``` sh +uv run python syncAddonWithTemplate.py -ad ../MyAddon +``` > [!NOTE] > Before applying any modifications, the script creates an untracked backup directory located next to the add-on folder named `_bak_`. @@ -113,14 +112,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 +``` sh uv sync uv run scons ``` If everything builds successfully, remove the `_bak_` directory, stage and commit the updated infrastructure: -```sh +``` sh git clean -f git add . git commit -m "chore: sync infrastructure with AddonTemplate" @@ -135,13 +134,42 @@ You can execute the script with various command-line arguments to customize the #### Available Options | Short Flag | Long Argument | Description | Default Value | -| :--- | :--- | :--- | :--- | +| --- | --- | --- | --- | | `-ad` | `--addon-dir` | Path to the root directory of the local add-on you want to update. If not specified, the script automatically walks up from your current directory to find `buildVars.py`. | Current working directory | | `-td` | `--template-dir` | Path to a local clone/directory of the NVDA `AddonTemplate`. When provided, the tool skips fetching the template via Git and synchronizes directly using this local reference. | None (clones from GitHub) | | `-dr` | `--dry-run` | Simulates the execution. It analyzes structure, logs planned changes, and builds reports without writing or modifying any file on disk. | Disabled | | `-s` | `--skip-backup` | Disables the automatic creation of a timestamped backup directory (e.g., `addonName_bak_YYYYMMDD_HHMMSS`) before processing updates. | Disabled (Backup is created) | | `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | +#### Running as a Standalone Executable (`syncAddonTool.exe`) + +For users or CI pipelines that prefer not to manage local Python environments, `tomlkit` installations, or folder dependencies, a standalone pre-packaged executable (`syncAddonTool.exe`) can be generated using PyInstaller. + +The executable bundle incorporates Python, `tomlkit`, and the complete `syncAddonTool/` package into a single, self-contained binary file that can be executed from anywhere on your system. + +##### 1. Generating the Executable + +Since the `syncAddonTool.spec` configuration file is provided directly at the root of the repository, you can build the standalone executable using `uv` and PyInstaller: + +``` sh +uv run --with pyinstaller pyinstaller syncAddonTool.spec +``` + +The compiled executable will be generated inside the `dist/` folder (`dist/syncAddonTool.exe`). + +##### 2. Running the Executable + +Once compiled or downloaded, `syncAddonTool.exe` accepts the exact same command-line flags (`-ad`, `-td`, `--dry-run`, `--skip-backup`) as the Python script: + +* **Targeting an add-on directory from anywhere:** +``` cmd +syncAddonTool.exe -ad C:\path\to\my-nvda-addon +``` +* **Performing a dry-run test:** +``` cmd +syncAddonTool.exe -ad C:\path\to\my-nvda-addon --dry-run +``` + #### Customizing Exclusions with `.addonmergeignore` Rather than modifying the `syncAddonWithTemplate.py` core source code or changing its internal `PROTECTED_ELEMENTS` array, the update tool includes a robust file-exclusion system driven by a local file named `.addonmergeignore`. @@ -185,79 +213,92 @@ addon/doc/fr/custom-extra-help.html #### Usage Examples -Depending on your workflow, the script can be executed either directly from within your add-on repository or from an external directory. +Depending on your workflow, the synchronization tool can be executed either directly from within your add-on repository or from an external directory using Python or the standalone executable. + +> [!NOTE] +> When executing the Python script from an external directory, remember to include both `syncAddonWithTemplate.py` and `syncAddonTool/` together, as highlighted in the [Prerequisites](https://www.google.com/search?q=%23prerequisites). ##### 1. Standard Automatic Update Downloads the latest remote template, creates a safety backup of your repository, and non-destructively synchronizes the machinery files. -* **Syntax A (Script inside the add-on repository):** - - ```sh - uv run python syncAddonWithTemplate.py - ``` - -* **Syntax B (Script outside the add-on repository):** - - ```sh - uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon - ``` +* **Syntax A (Python script inside the add-on repository):** +``` sh +uv run python syncAddonWithTemplate.py +``` +* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** +``` sh +uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon +``` +* **Syntax C (Standalone executable):** +``` cmd +syncAddonTool.exe -ad C:\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):** - - ```sh - uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate - ``` - -* **Syntax B (Script outside the add-on repository):** +Useful when testing local modifications applied to `AddonTemplate` or when working without an active internet connection. - ```sh - uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate - ``` +* **Syntax A (Python script inside the add-on repository):** +``` sh +uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate +``` +* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** +``` sh +uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate +``` +* **Syntax C (Standalone executable):** +``` cmd +syncAddonTool.exe -ad C:\path\to\my-nvda-addon -td C:\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):** - - ```sh - uv run python syncAddonWithTemplate.py --dry-run - ``` - -* **Syntax B (Script outside the add-on repository):** +Analyzes structural layouts, evaluates configurations, reads `.addonmergeignore` directives, and builds reports without writing anything to disk. - ```sh - uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon - ``` +* **Syntax A (Python script inside the add-on repository):** +``` sh +uv run python syncAddonWithTemplate.py --dry-run +``` +* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** +``` sh +uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon +``` +* **Syntax C (Standalone executable):** +``` cmd +syncAddonTool.exe --dry-run -ad C:\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):** - - ```sh - uv run python syncAddonWithTemplate.py --skip-backup - ``` - -* **Syntax B (Script outside the add-on repository):** +Targets a project repository while skipping the automated safety backup creation phase to speed up execution. - ```sh - uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup - ``` +* **Syntax A (Python script inside the add-on repository):** +``` sh +uv run python syncAddonWithTemplate.py --skip-backup +``` +* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** +``` sh +uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup +``` +* **Syntax C (Standalone executable):** +``` cmd +syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup +``` -##### 5. Run without Installation (`--with` option) +##### 5. Run without Prior 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: +When using the Python script, if you wish to execute the synchronization tool without installing its required third-party dependencies (like `tomlkit`) into your active environment beforehand, you can request `uv` to expose them temporarily during command execution: -```sh +* **Using Python with `uv`:** +``` sh uv run --with tomlkit python syncAddonWithTemplate.py ``` +* **Using Standalone Executable:** +*(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* +``` cmd +syncAddonTool.exe +``` --- diff --git a/syncAddonTool/cli.py b/syncAddonTool/cli.py index f1b2044..817a558 100644 --- a/syncAddonTool/cli.py +++ b/syncAddonTool/cli.py @@ -114,7 +114,7 @@ def main() -> None: if parsedArgs.dryRun: logger.debug("Safety backup skipped (simulation mode active).") elif parsedArgs.skipBackup: - logger.debug("Safety backup skipped (--skip-backup requested by user).") + logger.info("Safety backup skipped (--skip-backup requested by user).") else: backupDirPath: str = f"{addonDir}_bak_{datetime.now().strftime('%Y%m%d_%H%M%S')}" logger.info("Creating safety automatic backup in: %s...", os.path.basename(backupDirPath)) From 723d86920d6a726567d187b2552436a55a83d916 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 9 Aug 2026 17:27:21 +0200 Subject: [PATCH 13/52] build(lint): exclude syncAddonTool folder and syncAddonTool.spec file from Pyright and Ruff Exclude syncAddonTool and syncAddonTool.spec from Pyright and Ruff checks in pyproject.toml. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 05f3347..2572908 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,6 +82,8 @@ exclude = [ ".venv", "buildVars.py", "syncAddonWithTemplate.py", + "syncAddonTool.spec", + "syncAddonTool", "tests", ] @@ -122,6 +124,8 @@ exclude = [ "site_scons", ".github/scripts", "syncAddonWithTemplate.py", + "syncAddonTool.spec", + "syncAddonTool", "tests", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, From 9a4afbef72f7b34ec00071708ac57efb5aa74d47 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 9 Aug 2026 23:37:43 +0200 Subject: [PATCH 14/52] build: ignore PyInstaller output directories Add `build/` and `dist/` to `.gitignore` to prevent generated executable artifacts from being tracked. --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a6ccee5..df802af 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ manifest.ini .sconsign.dblite /[0-9]*.[0-9]*.[0-9]*.json *.egg-info +dist/ +build/ From c48885bbc15d0e753645d91dcb21af1695a6fa41 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 9 Aug 2026 23:40:17 +0200 Subject: [PATCH 15/52] fix(sync): remove extra empty lines in generated TOML files Clean up multiple consecutive newlines in `fixTomlIndentation` using regex to guarantee at most a single empty line between TOML sections. --- syncAddonTool/utils.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncAddonTool/utils.py b/syncAddonTool/utils.py index 46dc88b..f500815 100644 --- a/syncAddonTool/utils.py +++ b/syncAddonTool/utils.py @@ -64,6 +64,7 @@ def fixTomlIndentation(tomlContentText: str) -> str: fixedLines.append(lineItem) outputText: str = "".join(fixedLines) + outputText = re.sub(r"\n{3,}", "\n\n", outputText) if not outputText.endswith("\n"): outputText += "\n" return outputText From d6a60500c32e68338545a04d697b5322b3079e32 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 10 Aug 2026 00:10:01 +0200 Subject: [PATCH 16/52] style(sync): add trailing empty line to some files --- syncAddonWithTemplate.py | 2 +- tests/unit/template/fixtures/templatePyproject.toml | 2 +- tests/unit/template/fixtures/userPyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py index 78ad229..3cbeee3 100644 --- a/syncAddonWithTemplate.py +++ b/syncAddonWithTemplate.py @@ -16,4 +16,4 @@ from syncAddonTool.cli import main if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tests/unit/template/fixtures/templatePyproject.toml b/tests/unit/template/fixtures/templatePyproject.toml index f5ba453..73e4301 100644 --- a/tests/unit/template/fixtures/templatePyproject.toml +++ b/tests/unit/template/fixtures/templatePyproject.toml @@ -14,4 +14,4 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=7.0.0", -] \ No newline at end of file +] diff --git a/tests/unit/template/fixtures/userPyproject.toml b/tests/unit/template/fixtures/userPyproject.toml index e57632a..7b75583 100644 --- a/tests/unit/template/fixtures/userPyproject.toml +++ b/tests/unit/template/fixtures/userPyproject.toml @@ -15,4 +15,4 @@ dependencies = [ [dependency-groups] dev = [ "pytest>=8.0.0", -] \ No newline at end of file +] From 8e3c1f4490e3ba5ac2ab11b93971f709a1d97a77 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 11 Aug 2026 08:50:56 +0200 Subject: [PATCH 17/52] - Remove obsolete `syncAddonWithTemplate.py` top-level script. - Move `syncAddonTool.spec` into the `syncAddonTool/` package directory. - Rename unit test module from `test_syncAddonWithTemplate.py` to `test_syncAddonTool.py` in `tests/unit/template/`. - Update `pyproject.toml` to remove redundant Pyright and Ruff exclusions for the old script and spec file (the spec file is now covered by the existing `syncAddonTool` directory rule). - Update `docs/unittesting.md` to reference `test_syncAddonTool.py`. --- docs/unitTesting.md | 2 +- syncAddonTool/__main__.py | 16 +++++++++++++--- .../syncAddonTool.spec | 2 +- syncAddonWithTemplate.py | 19 ------------------- ...nWithTemplate.py => test_syncAddonTool.py} | 6 +++--- 5 files changed, 18 insertions(+), 27 deletions(-) rename syncAddonTool.spec => syncAddonTool/syncAddonTool.spec (98%) delete mode 100644 syncAddonWithTemplate.py rename tests/unit/template/{test_syncAddonWithTemplate.py => test_syncAddonTool.py} (98%) diff --git a/docs/unitTesting.md b/docs/unitTesting.md index 74ee7fb..70ee851 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -28,7 +28,7 @@ You can run individual test modules during development by specifying their path: * **Add-on Synchronization Tool Tests:** ``` bash - uv run python -m unittest -v tests/unit/template/test_syncAddonWithTemplate.py + uv run python -m unittest -v tests/unit/template/test_syncAddonTool ``` --- diff --git a/syncAddonTool/__main__.py b/syncAddonTool/__main__.py index e145b27..829af95 100644 --- a/syncAddonTool/__main__.py +++ b/syncAddonTool/__main__.py @@ -2,9 +2,19 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -"""CLI entry point execution wrapper when invoked with python -m syncAddonTool.""" +"""CLI entry point for the NVDA Add-on Template synchronization tool.""" -from .cli import main +from pathlib import Path +import sys + +# Ensure the repository root directory is on sys.path so the `syncAddonTool` +# package can be properly resolved regardless of the execution mode. +PACKAGE_DIR = Path(__file__).resolve().parent +REPO_ROOT = PACKAGE_DIR.parent +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from syncAddonTool.cli import main if __name__ == "__main__": - main() + main() diff --git a/syncAddonTool.spec b/syncAddonTool/syncAddonTool.spec similarity index 98% rename from syncAddonTool.spec rename to syncAddonTool/syncAddonTool.spec index cbb5a27..8d8e22b 100644 --- a/syncAddonTool.spec +++ b/syncAddonTool/syncAddonTool.spec @@ -72,7 +72,7 @@ VERSION_FILE_PATH.write_text(str(version_info), encoding="utf-8") # PyInstaller Analysis and Collection # ----------------------------------------------------------------------------- a = Analysis( - ["syncAddonWithTemplate.py"], + ["__main__.py"], pathex=[str(ROOT_DIR)], binaries=[], datas=[], diff --git a/syncAddonWithTemplate.py b/syncAddonWithTemplate.py deleted file mode 100644 index 3cbeee3..0000000 --- a/syncAddonWithTemplate.py +++ /dev/null @@ -1,19 +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. - -"""CLI entry point for the NVDA Add-on Template synchronization tool.""" - -from pathlib import Path -import sys - -# Insert the script's parent directory at the beginning of sys.path. -# This ensures syncAddonTool package resolution regardless of the current working directory. -SCRIPT_DIR: Path = Path(__file__).resolve().parent -if str(SCRIPT_DIR) not in sys.path: - sys.path.insert(0, str(SCRIPT_DIR)) - -from syncAddonTool.cli import main - -if __name__ == "__main__": - main() diff --git a/tests/unit/template/test_syncAddonWithTemplate.py b/tests/unit/template/test_syncAddonTool.py similarity index 98% rename from tests/unit/template/test_syncAddonWithTemplate.py rename to tests/unit/template/test_syncAddonTool.py index eb99d38..890f0bf 100644 --- a/tests/unit/template/test_syncAddonWithTemplate.py +++ b/tests/unit/template/test_syncAddonTool.py @@ -29,15 +29,15 @@ def load_tests( Enforces test execution in source code definition order using class dict insertion order. """ orderIndex: dict[str, int] = { - name: i for i, name in enumerate(TestSyncAddonWithTemplate.__dict__) + name: i for i, name in enumerate(TestSyncAddonTool.__dict__) } loader.sortTestMethodsUsing = ( lambda a, b: orderIndex.get(a, 999) - orderIndex.get(b, 999) ) - return loader.loadTestsFromTestCase(TestSyncAddonWithTemplate) + return loader.loadTestsFromTestCase(TestSyncAddonTool) -class TestSyncAddonWithTemplate(unittest.TestCase): +class TestSyncAddonTool(unittest.TestCase): """Test cases for checking synchronization logic and file merges.""" def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: From 7c7b92e757dd7e557f08c55bd1064266bbf9e258 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 11 Aug 2026 17:27:33 +0200 Subject: [PATCH 18/52] Update docs/managementFromGit/updatingExistingAddons.md to document the syncAddonTool workflow for syncing add-ons with AddonTemplate. --- docs/l10n/addonAuthors.md | 18 +- .../updatingExistingAddons.md | 528 ++++++++++-------- pyproject.toml | 4 - 3 files changed, 315 insertions(+), 235 deletions(-) diff --git a/docs/l10n/addonAuthors.md b/docs/l10n/addonAuthors.md index 5fa5831..2e4d1ad 100644 --- a/docs/l10n/addonAuthors.md +++ b/docs/l10n/addonAuthors.md @@ -12,11 +12,27 @@ If you wish to use the community project [Crowdin project to translate NVDA add- * **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**), or in the [NVDA Add-ons Mailing List](https://groups.io/g/nvda-addons) (**nvda-addons@groups.io**), requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. +### Required Token Scope & Permissions + +To maintain minimum necessary privileges, configure your token in **Account Settings > API > New Token** with the following settings: + +1. **Project Restriction:** Under **Projects**, choose **Selected projects** and select only the NVDA add-ons community project. +1. **Scopes:** Select only these permissions: + * **`projects` (Read):** Required to retrieve project settings and configuration. + * **`source-files` (Read & Write):** Required to upload `.pot` and `.xliff` source files. + * **`translations` (Read & Write):** Required to download translated `.po` and `.xliff` files and verify translation progress. + +> **Note:** Leave all other scopes (*User*, *Webhooks*, *Screenshots*, *Reports*, *Billing*) unchecked, as they are not required by the synchronization scripts. + ## GitHub Secrets and Variables To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): -* `CROWDIN_TOKEN`: Paste your Crowdin API token here. +1. In the **Repository secrets** section, click **New repository secret**. +1. In the **Name** field, enter `CROWDIN_TOKEN`. +1. In the **Secret** field, paste your Crowdin API token. +1. Click **Add secret** to save it. +Once added, the token will be available to your repository's workflows. Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), you can create repository variables from **Settings > Secrets and variables > Actions > Variables** by selecting the **Variables** tab and clicking **New repository variable**. diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 64fb589..6392980 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -1,125 +1,157 @@ # Integrating the add-on template in your add-on -## Pre-requisites for initial setup +## Pre-requisites -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. +1. Clone the repository: -2. Clone the repository to your local computer: + ```sh + git clone https://github.com/{repoName}.git + ``` - ```sh - 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. -3. Go to the folder where your repository was cloned: +1. Go to the folder where your repository was cloned: - ```sh - cd {repoFolder} - ``` + ```sh + cd {repoFolder} + ``` -4. In this folder, create an `addon` subfolder and store the code for your add-on. +1. Commit your changes: -5. Commit your initial changes: + ```sh + git add . + git commit -m "Initial commit" + ``` - ```sh - git add . - 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 -AddonTemplate evolves over time and regularly receives improvements, bug fixes, new GitHub workflows, and build system updates. +As AddonTemplate evolves, it 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. +*This document explains the update procedures, including both the recommended automated method using `syncAddonTool` and the manual Git merge 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: +Before updating your repository: -* **Check repository status**: - Ensure your working tree is clean. +- Ensure your working tree is clean. ```sh git status ``` -* **Commit or stash**: - Save or stash any pending local modifications. +- Commit or stash any pending changes. -* **Use a dedicated branch**: - It is highly recommended to perform the update on a separate, dedicated branch to isolate changes. +- 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: + +```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 +``` ## 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`. +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: `syncAddonTool`. -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 tool 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: +The tool 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`. +- **Legacy Structure (Dictionary-based without pyproject.toml):** + For older add-ons where `addon_info` was defined as a standard dictionary, the tool automatically migrates the metadata to the modern `AddonInfo` object structure. + It generates a new, fully populated `pyproject.toml` file matching the latest template standards, and synchronizes all infrastructure files. + +- **Modern Structure (AddonInfo-based):** + For newer add-ons that already use the `AddonInfo` object but need upstream template updates, the tool checks for any missing metadata keys in `buildVars.py` to insert them. + It safely updates `pyproject.toml` dependencies and versions while preserving your custom configuration rules for tools like `pyright` and `ruff`. ### Prerequisites Before running the tool, ensure your system meets the following requirements: -* **Python**: -Version **3.13** or newer must be installed (matching the template's required Python version). -* **Git**: -Git must be installed and available in your system `PATH`. -* For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: -Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). +- **Python**: + Version **3.13** or newer must be installed (matching the template's required Python version). + +- **Git**: + Git must be installed and available in your system `PATH`. + +- For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: + Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). > [!IMPORTANT] -> **Project Structure & Remote Execution:** -> The update engine is structured as a Python package. The entry script `syncAddonWithTemplate.py` relies on the adjacent `syncAddonTool/` package directory. -> If you choose to copy or run the Python script from an external directory outside of the repository, **you must copy both `syncAddonWithTemplate.py` AND the `syncAddonTool/` directory together** into that external location. Alternatively, you can use the standalone executable (`syncAddonTool.exe`), which requires no external folders or Python dependencies. +> **Project Structure & Execution Methods:** +> The update engine is structured as a Python package that relies on the `syncAddonTool/` directory layout. +> You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run python syncAddonTool`. +> Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run python -m syncAddonTool -ad /path/to/my-nvda-addon`. +> Finally, you can use the standalone executable (`syncAddonTool.exe`), which requires no Python dependencies. ### Running the automated tool -The script is highly flexible and supports two execution modes: +The tool is highly flexible and supports two execution modes: 1. **Standard Mode (No arguments):** -Run the script directly from the root of your repository or from any of its subdirectories. -It will automatically locate the project root by searching for `buildVars.py`. -``` sh -uv run python syncAddonWithTemplate.py -``` + Run the tool 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 syncAddonTool + ``` + 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 -``` + Run the tool 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 syncAddonTool -ad ../MyAddon + ``` > [!NOTE] -> Before applying any modifications, the script creates an untracked backup directory located next to the add-on folder named `_bak_`. +> Before applying any modifications, the tool 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 +```sh uv sync uv run scons ``` If everything builds successfully, remove the `_bak_` directory, stage and commit the updated infrastructure: -``` sh +```sh git clean -f git add . git commit -m "chore: sync infrastructure with AddonTemplate" @@ -127,52 +159,49 @@ git commit -m "chore: sync infrastructure with AddonTemplate" ### Using the Update Tool via Command Line -The `syncAddonWithTemplate.py` script provides a non-destructive industrial update engine to align your local add-on repository layout with the latest structure of the official NVDA `AddonTemplate`. +The `syncAddonTool` tool 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. +You can execute the tool with various command-line arguments to customize the update workflow. #### Available Options | Short Flag | Long Argument | Description | Default Value | -| --- | --- | --- | --- | +| :--- | :--- | :--- | :--- | | `-ad` | `--addon-dir` | Path to the root directory of the local add-on you want to update. If not specified, the script automatically walks up from your current directory to find `buildVars.py`. | Current working directory | | `-td` | `--template-dir` | Path to a local clone/directory of the NVDA `AddonTemplate`. When provided, the tool skips fetching the template via Git and synchronizes directly using this local reference. | None (clones from GitHub) | | `-dr` | `--dry-run` | Simulates the execution. It analyzes structure, logs planned changes, and builds reports without writing or modifying any file on disk. | Disabled | | `-s` | `--skip-backup` | Disables the automatic creation of a timestamped backup directory (e.g., `addonName_bak_YYYYMMDD_HHMMSS`) before processing updates. | Disabled (Backup is created) | | `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | -#### Running as a Standalone Executable (`syncAddonTool.exe`) - -For users or CI pipelines that prefer not to manage local Python environments, `tomlkit` installations, or folder dependencies, a standalone pre-packaged executable (`syncAddonTool.exe`) can be generated using PyInstaller. - -The executable bundle incorporates Python, `tomlkit`, and the complete `syncAddonTool/` package into a single, self-contained binary file that can be executed from anywhere on your system. - ##### 1. Generating the Executable -Since the `syncAddonTool.spec` configuration file is provided directly at the root of the repository, you can build the standalone executable using `uv` and PyInstaller: +Since the `syncAddonTool.spec` configuration file is provided inside the `syncAddonTool` directory, you can build the standalone executable using `uv` and PyInstaller: -``` sh -uv run --with pyinstaller pyinstaller syncAddonTool.spec +```sh +uv run --with pyinstaller pyinstaller syncAddonTool/syncAddonTool.spec ``` The compiled executable will be generated inside the `dist/` folder (`dist/syncAddonTool.exe`). ##### 2. Running the Executable -Once compiled or downloaded, `syncAddonTool.exe` accepts the exact same command-line flags (`-ad`, `-td`, `--dry-run`, `--skip-backup`) as the Python script: +Once compiled or downloaded, `syncAddonTool.exe` accepts the exact same command-line flags (`-ad`, `-td`, `--dry-run`, `--skip-backup`) as the Python execution modes: -* **Targeting an add-on directory from anywhere:** -``` cmd -syncAddonTool.exe -ad C:\path\to\my-nvda-addon -``` -* **Performing a dry-run test:** -``` cmd -syncAddonTool.exe -ad C:\path\to\my-nvda-addon --dry-run -``` +- **Targeting an add-on directory from anywhere**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon + ``` + +- **Performing a dry-run test**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon --dry-run + ``` #### Customizing Exclusions with `.addonmergeignore` -Rather than modifying the `syncAddonWithTemplate.py` core source code or changing its internal `PROTECTED_ELEMENTS` array, the update tool includes a robust file-exclusion system driven by a local file named `.addonmergeignore`. +Rather than modifying the `syncAddonTool` 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. @@ -180,9 +209,9 @@ This architectural design allows developers to cleanly decouple their project-sp 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, relative paths, or glob patterns of the files or folders you want the tool to skip during synchronization. -* The file uses standard `.gitignore` pattern matching syntax (parsed via `pathspec`). -* You can write one pattern per line. Empty lines and lines starting with `#` are automatically treated as comments and ignored. +- Inside this file, list the names, relative paths, or glob patterns of the files or folders you want the tool to skip during synchronization. +- The file uses standard `.gitignore` pattern matching syntax (parsed via `pathspec`). +- 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 specific workflows, simply add them to the file: @@ -205,122 +234,187 @@ addon/doc/fr/custom-extra-help.html 3. **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). + Since the tool 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). 4. **File Location Requirement:** The update engine always loads custom exclusions from the target add-on's root folder being updated. - Therefore, **the `.addonmergeignore` file must always reside inside the destination add-on directory**, even if you are executing the `syncAddonWithTemplate.py` script from a completely different directory or an external workspace. + Therefore, **the `.addonmergeignore` file must always reside inside the destination add-on directory**, even if you are executing the `syncAddonTool` tool from a completely different directory or an external workspace. #### Usage Examples -Depending on your workflow, the synchronization tool can be executed either directly from within your add-on repository or from an external directory using Python or the standalone executable. - -> [!NOTE] -> When executing the Python script from an external directory, remember to include both `syncAddonWithTemplate.py` and `syncAddonTool/` together, as highlighted in the [Prerequisites](https://www.google.com/search?q=%23prerequisites). +Depending on your workflow, the synchronization tool can be executed directly using `uv` with `syncAddonTool` directory, via Python module flags (`-m`), or using the standalone executable (`syncAddonTool.exe`). ##### 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 (Python script inside the add-on repository):** -``` sh -uv run python syncAddonWithTemplate.py -``` -* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** -``` sh -uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -``` -* **Syntax C (Standalone executable):** -``` cmd -syncAddonTool.exe -ad C:\path\to\my-nvda-addon -``` +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool + ``` + +- **Syntax B (Module execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool + ``` + +- **Syntax C (Module path execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool.py + ``` + +- **Syntax D (Targeting an external add-on directory)**: + + ```sh + uv run python -m syncAddonTool -ad /path/to/my-nvda-addon + ``` + +- **Syntax E (Standalone executable)**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon + ``` ##### 2. Updating from a Local Template Cache (Offline/Development) Useful when testing local modifications applied to `AddonTemplate` or when working without an active internet connection. -* **Syntax A (Python script inside the add-on repository):** -``` sh -uv run python syncAddonWithTemplate.py -td /path/to/local/AddonTemplate -``` -* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** -``` sh -uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate -``` -* **Syntax C (Standalone executable):** -``` cmd -syncAddonTool.exe -ad C:\path\to\my-nvda-addon -td C:\path\to\local\AddonTemplate -``` +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool -td /path/to/local/AddonTemplate + ``` + +- **Syntax B (Module execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool -td /path/to/local/AddonTemplate + ``` + +- **Syntax C (Module path execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool.py -td /path/to/local/AddonTemplate + ``` + +- **Syntax D (Targeting an external add-on directory)**: + + ```sh + uv run python -m syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate + ``` + +- **Syntax E (Standalone executable)**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon -td C:\path\to\local\AddonTemplate + ``` ##### 3. Simulating Changes Safely (Dry Run) Analyzes structural layouts, evaluates configurations, reads `.addonmergeignore` directives, and builds reports without writing anything to disk. -* **Syntax A (Python script inside the add-on repository):** -``` sh -uv run python syncAddonWithTemplate.py --dry-run -``` -* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** -``` sh -uv run python /path/to/syncAddonWithTemplate.py --dry-run -ad /path/to/my-nvda-addon -``` -* **Syntax C (Standalone executable):** -``` cmd -syncAddonTool.exe --dry-run -ad C:\path\to\my-nvda-addon -``` +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool --dry-run + ``` + +- **Syntax B (Module execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool --dry-run + ``` + +- **Syntax C (Module path execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool.py --dry-run + ``` + +- **Syntax D (Targeting an external add-on directory)**: + + ```sh + uv run python -m syncAddonTool --dry-run -ad /path/to/my-nvda-addon + ``` + +- **Syntax E (Standalone executable)**: + + ```cmd + syncAddonTool.exe --dry-run -ad C:\path\to\my-nvda-addon + ``` ##### 4. Speeding Up with Backup Omission Targets a project repository while skipping the automated safety backup creation phase to speed up execution. -* **Syntax A (Python script inside the add-on repository):** -``` sh -uv run python syncAddonWithTemplate.py --skip-backup -``` -* **Syntax B (Python script outside the add-on repository — requires both `syncAddonWithTemplate.py` and `syncAddonTool/`):** -``` sh -uv run python /path/to/syncAddonWithTemplate.py -ad /path/to/my-nvda-addon --skip-backup -``` -* **Syntax C (Standalone executable):** -``` cmd -syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup -``` +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool --skip-backup + ``` + +- **Syntax B (Module execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool --skip-backup + ``` + +- **Syntax C (Module path execution inside the add-on repository)**: + + ```sh + uv run python -m syncAddonTool.py --skip-backup + ``` + +- **Syntax D (Targeting an external add-on directory)**: + + ```sh + uv run python -m syncAddonTool -ad /path/to/my-nvda-addon --skip-backup + ``` + +- **Syntax E (Standalone executable)**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup + ``` ##### 5. Run without Prior Installation (`--with` option) -When using the Python script, if you wish to execute the synchronization tool without installing its required third-party dependencies (like `tomlkit`) into your active environment beforehand, you can request `uv` to expose them temporarily during command execution: +If you wish to execute the synchronization tool without installing its required third-party dependencies (like `tomlkit`) into your active environment beforehand, you can request `uv` to expose them temporarily during command execution: -* **Using Python with `uv`:** -``` sh -uv run --with tomlkit python syncAddonWithTemplate.py -``` -* **Using Standalone Executable:** -*(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* -``` cmd -syncAddonTool.exe -``` +- **Using directory execution with `uv`**: ---- + ```sh + uv run --with tomlkit python syncAddonTool + ``` -## Alternative Method: Manual Update Using Git Merge +- **Using module execution with `uv`**: -If you prefer not to use the automated tool, you can manually merge the latest version of AddonTemplate into your repository. + ```sh + uv run --with tomlkit python -m syncAddonTool + ``` -### Fetching the add-on template repository +- **Using module path execution with `uv`**: -1. If you haven't done it yet, from your add-on repository, add the addonTemplate as a remote. + ```sh + uv run --with tomlkit python -m syncAddonTool.py + ``` -```sh -git remote add template https://github.com/nvaccess/AddonTemplate.git -``` +- **Using Standalone Executable**: -2. Fetch the template: + *(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* -```sh -git fetch template -``` + ```cmd + syncAddonTool.exe + ``` + +--- -### Merging the latest template +## 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. Merge the latest version of AddonTemplate: @@ -328,17 +422,13 @@ Merge the latest version of AddonTemplate: git merge template/master --allow-unrelated-histories --squash ``` -* **Why `--allow-unrelated-histories`?** - This 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. -* **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. +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. At this stage, Git may report merge conflicts. -This is completely normal. ---- +This is completely normal. ## Understanding merge conflicts @@ -349,14 +439,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` option is used to determine where files to be restored can be found. +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. @@ -366,14 +456,15 @@ To keep your `.md` files from your add-on repository, ensuring they aren't repla git restore *.md --source=HEAD ``` -#### Remove the template documentation +### Remove the template documentation The `docs/` directory belongs to AddonTemplate itself. + It is not intended to become part of your add-on repository. Remove it: -```sh +``` git rm -r docs ``` @@ -383,55 +474,54 @@ Or use the restore command: git restore docs --source=HEAD ``` -#### Resolve `buildVars.py` +### Resolve buildVars.py `buildVars.py` usually contains merge conflicts because it includes both: -* information specific to your add-on; -* variables introduced by newer versions of AddonTemplate. +- information specific to your add-on; +- variables introduced by newer versions of AddonTemplate. Review the file carefully. In general: -* keep your add-on metadata; -* preserve your version number; -* keep your custom settings; -* add any new variables introduced by the template. +- keep your add-on metadata; +- preserve your version number; +- keep your custom settings; +- add any new variables introduced by the template. -#### Resolve `pyproject.toml` +### Resolve pyproject.toml `pyproject.toml` is another file that commonly requires manual review. Keep your project-specific configuration while incorporating any new settings required by the updated template. -#### Other files +### Other files -For most remaining infrastructure files, the version provided by AddonTemplate is generally the correct one. +For most remaining files, the version provided by AddonTemplate is generally the correct one. Typical examples include: -* `.github/` -* `.gitignore` -* `manifest.ini.tpl` -* `manifest-translated.ini.tpl` -* `site_scons/` -* `sconstruct` +- `.github/` +- `.gitignore` +- `manifest.ini.tpl` +- `manifest-translated.ini.tpl` +- `site_scons/` +- `sconstruct` Review any conflicts if necessary before completing the merge. ---- - -### Completing the merge +## Completing the merge Once all conflicts have been resolved, check if the add-on can be built properly: ```sh -uv sync -uv run scons +uv sync # Update dependencies +uv run scons # Build the add-on ``` -If everything builds successfully, stage the modified files: + +If all is right, stage the modified files: ```sh git add . @@ -440,15 +530,13 @@ git add . Then create the merge commit: ```sh -git commit -m "chore: sync infrastructure with AddonTemplate" +git commit ``` ---- - -## Summary of File Actions +## Summary | File or directory | Recommended action | -| :--- | :--- | +|-------------------|--------------------| | `README.md` | Keep the add-on version | | `CHANGELOG.md` | Keep the add-on version | | `docs/` | Remove | @@ -456,8 +544,6 @@ 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 @@ -468,44 +554,26 @@ Most conflicts occur in `buildVars.py` and `pyproject.toml`. Review the conflicting sections carefully and combine the changes from both versions. -If you are unsure whether a change comes from your add-on or from AddonTemplate, compare the conflicting section with the latest version of AddonTemplate before resolving it. - ### I want to cancel the update -#### If 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: +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: ```sh -git restore . --source=HEAD +git merge --abort ``` -#### 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 passed the `--squash` flag, `git merge --abort` won't work. +In this case, you can use the restore command: ```sh -git merge --abort +git restore . --staged # Discard changes added to the staging area (after using `git add .`) ``` -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 manually with: - ```sh -git restore . --staged -git restore . --source=HEAD +git restore . --source=HEAD # Restores the working directory to the last commit made in your add-on repository ``` -If you have already committed the update and want to return to the previous state, you can reset your branch: +If you committed changes, you can use: ```sh git reset --hard {cleanBranch} diff --git a/pyproject.toml b/pyproject.toml index 2572908..e473a0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,8 +81,6 @@ exclude = [ "__pycache__", ".venv", "buildVars.py", - "syncAddonWithTemplate.py", - "syncAddonTool.spec", "syncAddonTool", "tests", ] @@ -123,8 +121,6 @@ exclude = [ ".venv", "site_scons", ".github/scripts", - "syncAddonWithTemplate.py", - "syncAddonTool.spec", "syncAddonTool", "tests", # When excluding concrete paths relative to a directory, From 97015328d9aae49157d4f4541d3303b155d8cd9e Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 11 Aug 2026 19:46:55 +0200 Subject: [PATCH 19/52] docs: remove invalid -m script syntax in syncAddonTool usage examples Remove instances of `python -m syncAddonTool.py` from updatingExistingAddons.md to keep only valid module and directory execution syntaxes. --- .../updatingExistingAddons.md | 78 +++---------------- 1 file changed, 12 insertions(+), 66 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 6392980..8287271 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -254,25 +254,13 @@ Downloads the latest remote template, creates a safety backup of your repository uv run python syncAddonTool ``` -- **Syntax B (Module execution inside the add-on repository)**: +- **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python -m syncAddonTool + uv run python syncAddonTool -ad /path/to/my-nvda-addon ``` -- **Syntax C (Module path execution inside the add-on repository)**: - - ```sh - uv run python -m syncAddonTool.py - ``` - -- **Syntax D (Targeting an external add-on directory)**: - - ```sh - uv run python -m syncAddonTool -ad /path/to/my-nvda-addon - ``` - -- **Syntax E (Standalone executable)**: +- **Syntax C (Standalone executable)**: ```cmd syncAddonTool.exe -ad C:\path\to\my-nvda-addon @@ -288,25 +276,13 @@ Useful when testing local modifications applied to `AddonTemplate` or when worki uv run python syncAddonTool -td /path/to/local/AddonTemplate ``` -- **Syntax B (Module execution inside the add-on repository)**: - - ```sh - uv run python -m syncAddonTool -td /path/to/local/AddonTemplate - ``` - -- **Syntax C (Module path execution inside the add-on repository)**: +- **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python -m syncAddonTool.py -td /path/to/local/AddonTemplate + uv run python syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` -- **Syntax D (Targeting an external add-on directory)**: - - ```sh - uv run python -m syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate - ``` - -- **Syntax E (Standalone executable)**: +- **Syntax D (Standalone executable)**: ```cmd syncAddonTool.exe -ad C:\path\to\my-nvda-addon -td C:\path\to\local\AddonTemplate @@ -322,25 +298,13 @@ Analyzes structural layouts, evaluates configurations, reads `.addonmergeignore` uv run python syncAddonTool --dry-run ``` -- **Syntax B (Module execution inside the add-on repository)**: - - ```sh - uv run python -m syncAddonTool --dry-run - ``` - -- **Syntax C (Module path execution inside the add-on repository)**: +- **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python -m syncAddonTool.py --dry-run + uv run python syncAddonTool --dry-run -ad /path/to/my-nvda-addon ``` -- **Syntax D (Targeting an external add-on directory)**: - - ```sh - uv run python -m syncAddonTool --dry-run -ad /path/to/my-nvda-addon - ``` - -- **Syntax E (Standalone executable)**: +- **Syntax C (Standalone executable)**: ```cmd syncAddonTool.exe --dry-run -ad C:\path\to\my-nvda-addon @@ -356,25 +320,13 @@ Targets a project repository while skipping the automated safety backup creation uv run python syncAddonTool --skip-backup ``` -- **Syntax B (Module execution inside the add-on repository)**: - - ```sh - uv run python -m syncAddonTool --skip-backup - ``` - -- **Syntax C (Module path execution inside the add-on repository)**: - - ```sh - uv run python -m syncAddonTool.py --skip-backup - ``` - -- **Syntax D (Targeting an external add-on directory)**: +- **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python -m syncAddonTool -ad /path/to/my-nvda-addon --skip-backup + uv run python syncAddonTool -ad /path/to/my-nvda-addon --skip-backup ``` -- **Syntax E (Standalone executable)**: +- **Syntax C (Standalone executable)**: ```cmd syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup @@ -396,12 +348,6 @@ If you wish to execute the synchronization tool without installing its required uv run --with tomlkit python -m syncAddonTool ``` -- **Using module path execution with `uv`**: - - ```sh - uv run --with tomlkit python -m syncAddonTool.py - ``` - - **Using Standalone Executable**: *(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* From 9abf590cf2a15957b95439d55282aaaa5baa3403 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 12 Aug 2026 07:55:30 +0200 Subject: [PATCH 20/52] docs(cli): document -v/--verbose flag and refine debug logging - Add missing -v/--verbose flag to CLI options table in docs/managementFromGit/updatingExistingAddons.md. - Suppress redundant dependency decision logs when running with --verbose. --- .../updatingExistingAddons.md | 1 + syncAddonTool/pyproject.py | 90 +++++++++++++------ 2 files changed, 64 insertions(+), 27 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 8287271..6db37ab 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -171,6 +171,7 @@ You can execute the tool with various command-line arguments to customize the up | `-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) | +| `-v` | `--verbose` | Enables detailed debug logging output (`[DEBUG]` level) in the console/log output. | Disabled (`[INFO]` level) | | `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | ##### 1. Generating the Executable diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index 7464149..0b164b4 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -61,7 +61,11 @@ def createPyprojectFromTemplate(templateFilePath: Path, metadataDict: dict[str, return tomlDoc -def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: +def mergeDependencyLists( + projList: list[Any], + tplList: list[Any], + contextName: str = "", +) -> list[Any]: """Intelligently merge two dependency lists by updating package versions based on base names. Preserves custom user dependencies and retains user version constraints strictly @@ -70,9 +74,30 @@ def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: :param projList: The existing project's dependency list. :param tplList: The template's dependency list. + :param contextName: Optional key/section name for logging context. :return: A merged list with updated versions and preserved custom or newer user items. """ - logger.debug("Merging dependency lists. User count: %d, Template count: %d", len(projList), len(tplList)) + isActualDependencyList: bool = contextName in ["dependencies", "dependency-groups"] + # Only emit decision logs if processing dependency-groups, preventing duplicate + # or premature decision logs on flat project.dependencies before migration/purge. + shouldLogDecisions: bool = contextName != "dependencies" and isActualDependencyList + + if projList or tplList: + contextLabel: str = f" [{contextName}]" if contextName else "" + if isActualDependencyList: + logger.debug( + "Merging dependency list%s. User count: %d, Template count: %d", + contextLabel, + len(projList), + len(tplList), + ) + else: + logger.debug( + "Merging list%s. User count: %d, Template count: %d", + contextLabel, + len(projList), + len(tplList), + ) projIndexByBase: dict[str, int] = {} itemIndex: int @@ -97,13 +122,14 @@ def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: userOriginalBaseName: str = getBasePackageName(userItemText) if userOriginalBaseName in REPLACED_PACKAGES: - logger.debug( - "DECISION [%s]: PACKAGE REPLACED (%s -> %s), FORCING TEMPLATE VERSION -> %r", - tplBaseName, - userOriginalBaseName, - tplBaseName, - tplItem, - ) + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: PACKAGE REPLACED (%s -> %s), FORCING TEMPLATE VERSION -> %r", + tplBaseName, + userOriginalBaseName, + tplBaseName, + tplItem, + ) mergedList.append(tplItem) continue @@ -116,29 +142,32 @@ def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: tplVersionTuple: tuple[int, ...] = tuple(map(int, tplMatch.group(1).split("."))) if userVersionTuple > tplVersionTuple: - logger.debug( - "DECISION [%s]: KEEP USER VERSION (%s > %s) -> %r", - tplBaseName, - userVersionTuple, - tplVersionTuple, - userItemText, - ) + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: KEEP USER VERSION (%s > %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + userItemText, + ) mergedList.append(userItemText) continue else: - logger.debug( - "DECISION [%s]: USE TEMPLATE VERSION (%s <= %s) -> %r", - tplBaseName, - userVersionTuple, - tplVersionTuple, - tplItem, - ) + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: USE TEMPLATE VERSION (%s <= %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + tplItem, + ) except ValueError: pass mergedList.append(tplItem) else: - logger.debug("DECISION [%s]: ADD TEMPLATE DEPENDENCY %r", tplBaseName, tplItem) + if shouldLogDecisions: + logger.debug("DECISION [%s]: ADD TEMPLATE DEPENDENCY %r", tplBaseName, tplItem) mergedList.append(tplItem) else: if tplItem not in mergedList: @@ -149,9 +178,15 @@ def mergeDependencyLists(projList: list[Any], tplList: list[Any]) -> list[Any]: if isinstance(depItem, str): baseName = getBasePackageName(depItem) if baseName in REPLACED_PACKAGES: - logger.debug("DECISION [%s]: REPLACED BY TEMPLATE EQUIVALENT %r", baseName, REPLACED_PACKAGES[baseName]) + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: REPLACED BY TEMPLATE EQUIVALENT %r", + baseName, + REPLACED_PACKAGES[baseName], + ) continue - logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY %r", depItem) + if shouldLogDecisions: + logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY %r", depItem) mergedList.append(depItem) return mergedList @@ -172,7 +207,7 @@ def deepMergeDicts(projDict: dict[str, Any], tplDict: dict[str, Any]) -> dict[st if isinstance(projVal, MutableMapping) and isinstance(dictValue, MutableMapping): deepMergeDicts(projVal, dictValue) elif isinstance(projVal, MutableSequence) and isinstance(dictValue, MutableSequence): - projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue)) + projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue), contextName=dictKey) else: pass else: @@ -284,6 +319,7 @@ def processDependencyGroupsMigration( if canonicalBaseName in migratedBaseNamesSet: logger.debug("Purging migrated dependency from flat list: %r", depItem) continue + logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY IN PROJECT.DEPENDENCIES %r", depItem) filteredProjectDepsList.append(depItem) mergedDictData["project"]["dependencies"] = filteredProjectDepsList From 8f8694bd6ff7d6b6bf104a0342d5fd5eb052d289 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 12 Aug 2026 21:34:52 +0200 Subject: [PATCH 21/52] fix(logging): include full TOML section path in list merge debug logs Prepend full TOML key path (e.g. tool.ruff.include) when logging list merges in pyproject.toml to eliminate ambiguity. --- syncAddonTool/pyproject.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index 0b164b4..ee13b44 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -192,22 +192,28 @@ def mergeDependencyLists( return mergedList -def deepMergeDicts(projDict: dict[str, Any], tplDict: dict[str, Any]) -> dict[str, Any]: +def deepMergeDicts( + projDict: dict[str, Any], + tplDict: dict[str, Any], + parentPath: str = "", +) -> dict[str, Any]: """Recursively merge tplDict into projDict. :param projDict: The original project dictionary to be updated. :param tplDict: The template dictionary whose values will be merged into projDict. + :param parentPath: Optional accumulated section prefix for context logging (e.g., 'tool.ruff'). :return: The updated projDict with merged values from tplDict. """ dictKey: str dictValue: Any for dictKey, dictValue in tplDict.items(): + fullPath: str = f"{parentPath}.{dictKey}" if parentPath else dictKey if dictKey in projDict: projVal: Any = projDict[dictKey] if isinstance(projVal, MutableMapping) and isinstance(dictValue, MutableMapping): - deepMergeDicts(projVal, dictValue) + deepMergeDicts(projVal, dictValue, parentPath=fullPath) elif isinstance(projVal, MutableSequence) and isinstance(dictValue, MutableSequence): - projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue), contextName=dictKey) + projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue), contextName=fullPath) else: pass else: From bfea2eff24b36f9482188193a51f835220f50f22 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 19 Sep 2026 22:40:15 +0200 Subject: [PATCH 22/52] docs: fix formatting and outdated descriptions in updatingExistingAddons.md - Fix invalid .gitignore / pathspec description for .addonmergeignore. - Fix broken markdown italics and stray quotation mark. - Fix section sequence label from Syntax D to Syntax C. - Put each sentence on a new line for better diff readability and Git tracking. --- .../updatingExistingAddons.md | 23 +++++++++++-------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 6db37ab..c9fa647 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -42,7 +42,7 @@ As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workfl You can merge the latest template changes into your repository instead of manually copying updated files. -*This document explains the update procedures, including both the recommended automated method using `syncAddonTool` and the manual Git merge workflow."* +*This document explains the update procedures, including both the recommended automated method using `syncAddonTool` and the manual Git merge 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. @@ -167,9 +167,11 @@ You can execute the tool with various command-line arguments to customize the up | 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 | +| `-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 | +| `-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) | | `-v` | `--verbose` | Enables detailed debug logging output (`[DEBUG]` level) in the console/log output. | Disabled (`[INFO]` level) | | `-h` | `--help` | Displays the default automated help menu listing all available parameters. | N/A | @@ -210,9 +212,10 @@ This architectural design allows developers to cleanly decouple their project-sp 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, relative paths, or glob patterns of the files or folders you want the tool to skip during synchronization. -- The file uses standard `.gitignore` pattern matching syntax (parsed via `pathspec`). -- You can write one pattern per line. Empty lines and lines starting with `#` are automatically treated as comments and ignored. +- Inside this file, list the exact relative paths, sub-folder paths, or pattern rules of the files or folders you want the tool to skip during synchronization. +- Patterns are matched using standard `fnmatch` rules (with support for `!` prefix to unprotect specific sub-paths). +- 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 specific workflows, simply add them to the file: @@ -227,10 +230,12 @@ addon/doc/fr/custom-extra-help.html ##### Crucial Requirements & Design Constraints 1. **Automatic Self-Exclusion:** - The update tool automatically protects `.addonmergeignore` itself from being overwritten during synchronization. Even if `.addonmergeignore` is present in the template repository, the target add-on's local `.addonmergeignore` file is preserved without needing to explicitly list itself. + The update tool automatically protects `.addonmergeignore` itself from being overwritten during synchronization. + Even if `.addonmergeignore` is present in the template repository, the target add-on's local `.addonmergeignore` file is preserved without needing to explicitly list itself. 2. **Presence Check on Initial Run:** - Before copying or updating any template files, the script explicitly checks whether `.addonmergeignore` is already present or absent at the root of the target add-on repository. If present, its custom rules are loaded immediately before processing any file transfers. + Before copying or updating any template files, the script explicitly checks whether `.addonmergeignore` is already present or absent at the root of the target add-on repository. + If present, its custom rules are loaded immediately before processing any file transfers. 3. **Case-Insensitivity:** The update tool evaluates exclusions using a standardized, case-insensitive matching algorithm. @@ -283,7 +288,7 @@ Useful when testing local modifications applied to `AddonTemplate` or when worki uv run python syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` -- **Syntax D (Standalone executable)**: +- **Syntax C (Standalone executable)**: ```cmd syncAddonTool.exe -ad C:\path\to\my-nvda-addon -td C:\path\to\local\AddonTemplate From 70b65c193b72c6b6811d7d86d6bfb5dc6e176827 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 19 Sep 2026 23:01:07 +0200 Subject: [PATCH 23/52] docs: update unit testing documentation for syncAddonTool - Fix python -m unittest command by adding .py extension to test path. - Update module location example to reference syncAddonTool package instead of legacy script. --- docs/unitTesting.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/unitTesting.md b/docs/unitTesting.md index 70ee851..e6303d2 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -2,11 +2,12 @@ This template provides a built-in unit testing structure powered by Python's standard `unittest` framework. -Ensuring your add-on and tooling behavior remains consistent during development and following template updates is done through automated unit testing. +Ensuring your add-on and tooling behavior remains consistent during development is done through automated unit testing. +Following template updates is also validated using these tests. ## Running Tests Locally -For unit tests to execute successfully, target modules (such as `syncAddonWithTemplate.py`) must be located at the root of the repository as sibling files to the `tests/` directory (at the same hierarchical level). This ensures Python's module discovery properly imports scripts when `unittest` runs from the project root. +For unit tests to execute successfully, target modules (such as `syncAddonTool`) must be located at the root of the repository as sibling files to the `tests/` directory (at the same hierarchical level). ### Run the Full Test Suite @@ -28,7 +29,7 @@ You can run individual test modules during development by specifying their path: * **Add-on Synchronization Tool Tests:** ``` bash - uv run python -m unittest -v tests/unit/template/test_syncAddonTool + uv run python -m unittest -v tests/unit/template/test_syncAddonTool.py ``` --- From 14a8a647af6042b53ccaad1d3c56074bb9c7b772 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 19 Sep 2026 23:16:03 +0200 Subject: [PATCH 24/52] build: remove unused pathspec hidden import from PyInstaller spec - Remove 'pathspec' from hiddenimports as it is not used by syncAddonTool. - Avoid PyInstaller warnings regarding missing hidden imports during build. --- syncAddonTool/syncAddonTool.spec | 1 - 1 file changed, 1 deletion(-) diff --git a/syncAddonTool/syncAddonTool.spec b/syncAddonTool/syncAddonTool.spec index 8d8e22b..a324c95 100644 --- a/syncAddonTool/syncAddonTool.spec +++ b/syncAddonTool/syncAddonTool.spec @@ -82,7 +82,6 @@ a = Analysis( "syncAddonTool.buildVarsSync", "syncAddonTool.pyproject", "tomlkit", - "pathspec", ], hookspath=[], hooksconfig={}, From 5e530b70f256e39f4764d1718e5e62a12b7138f0 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 19 Sep 2026 23:29:30 +0200 Subject: [PATCH 25/52] build: revert pyright version update in pyproject.toml - Revert 'pyright[nodejs]' dependency version back to 1.1.407 as requested. - Ensure dependency updates are handled in a separate dedicated PR. --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e473a0b..ae2bdce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,7 @@ lint = [ "uv==0.11.15", "ruff==0.14.5", "prek==0.4.8", - "pyright[nodejs]==1.1.411", + "pyright[nodejs]==1.1.407", ] dev = [ { include-group = "build" }, From 3887d582e91fdebceb8f3711d91fd8a8ff1f64d2 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:30:13 +0200 Subject: [PATCH 26/52] fix(astUtils): preserve non-constant unparsed AST expressions in dictionary conversion Safely fallback to `ast.unparse` when dict keys or keyword values are dynamic expressions instead of literal constants. --- syncAddonTool/astUtils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/syncAddonTool/astUtils.py b/syncAddonTool/astUtils.py index ea8f9ef..c4a3fc5 100644 --- a/syncAddonTool/astUtils.py +++ b/syncAddonTool/astUtils.py @@ -23,7 +23,10 @@ def parseAstDict(dictNode: ast.Dict) -> dict[str, Any]: keyName: Any = getattr(keyNode, "value", None) if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": valNode = valNode.args[0] - valValue: Any = getattr(valNode, "value", None) + if isinstance(valNode, ast.Constant): + valValue: Any = valNode.value + else: + valValue = ast.unparse(valNode) if keyName is not None: extractedData[keyName] = valValue return extractedData @@ -42,7 +45,10 @@ def parseAstKeywords(keywordList: list[ast.keyword]) -> dict[str, Any]: valNode: ast.expr = keywordItem.value if isinstance(valNode, ast.Call) and getattr(valNode.func, "id", None) == "_": valNode = valNode.args[0] - valValue: Any = getattr(valNode, "value", None) + if isinstance(valNode, ast.Constant): + valValue: Any = valNode.value + else: + valValue = ast.unparse(valNode) if keyName is not None: extractedData[keyName] = valValue return extractedData From 495969aff296a8e9e53a447f994c33a44367e427 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:32:56 +0200 Subject: [PATCH 27/52] fix(buildVarsSync): re-raise SyntaxError on corrupted buildVars.py parsing Ensure syntax errors encountered during AST parsing log details and raise immediately to avoid proceeding with incomplete metadata. --- syncAddonTool/buildVarsSync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 26892c8..3814356 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -29,7 +29,7 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict parsedTree: ast.AST = ast.parse(f.read()) except SyntaxError as syntaxErrorObj: logger.error("Syntax error while reading %s: %s", fileRootPath, syntaxErrorObj) - return {}, {} + raise metadataDict: dict[str, Any] = {} globalVarsDict: dict[str, tuple[ast.AST, str]] = {} From cf1dff59b825f357a7537d70901263a3eb102b17 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:35:32 +0200 Subject: [PATCH 28/52] fix(engine): enforce implicit .addonmergeignore protection and fnmatch path filtering - Add .addonmergeignore to default protected scope during workspace merges. - Implement fnmatch pattern matching for relative paths and file basenames. --- syncAddonTool/engine.py | 37 +++++++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py index cfaf60d..ce06e17 100644 --- a/syncAddonTool/engine.py +++ b/syncAddonTool/engine.py @@ -6,6 +6,7 @@ import ast import logging +import fnmatch import os from pathlib import Path import shutil @@ -37,18 +38,19 @@ def setupAddonMergeIgnore(tempDir: str | Path, addonDir: str | Path, dryRun: boo logger.info("Bootstrapped missing .addonmergeignore from template.") -def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> None: +def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> bool: """Synchronize 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. - :return: None + :return: True if synchronization completed without errors, False otherwise. """ logger.info("Phase 4: Synchronizing template machinery files...") setupAddonMergeIgnore(tempDir, addonDir, dryRun) protectedElementsSet: set[str] = { + ".addonmergeignore", "readme.md", "changelog.md", "addontemplate.egg-info", @@ -86,6 +88,21 @@ def addReportEntry(reportEntryText: str) -> None: if reportEntryText not in syncReportList: syncReportList.append(reportEntryText) + def isPathProtected(relPathText: str) -> bool: + """Check whether a given relative path matches any defined protection rules or wildcard patterns. + + :param relPathText: Relative path string to verify. + :return: True if the path matches a protected element or pattern, False otherwise. + """ + pathNormalizedText: str = relPathText.replace("\\", "/").lower() + patternItem: str + for patternItem in protectedElementsSet: + if pathNormalizedText == patternItem or fnmatch.fnmatch(pathNormalizedText, patternItem): + return True + if fnmatch.fnmatch(os.path.basename(pathNormalizedText), patternItem): + return True + return False + def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: """Inspect directory recursively for protected elements and copy non-protected files. @@ -103,8 +120,7 @@ def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: dirNameItem: str for dirNameItem in walkDirs: relPathText: str = dirNameItem if relDirPath == "." else os.path.join(relDirPath, dirNameItem) - relPathNormalizedText: str = relPathText.replace("\\", "/").lower() - if relPathNormalizedText in protectedElementsSet: + if isPathProtected(relPathText): displayPathText: str = relPathText.replace("\\", "/") addReportEntry(f"- **{displayPathText}/**: skipped (protected scope)") else: @@ -117,8 +133,7 @@ def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: fileNameItem: str for fileNameItem in walkFiles: relPathText = fileNameItem if relDirPath == "." else os.path.join(relDirPath, fileNameItem) - relPathNormalizedText = relPathText.replace("\\", "/").lower() - if relPathNormalizedText in protectedElementsSet: + if isPathProtected(relPathText): displayPathText = relPathText.replace("\\", "/") addReportEntry(f"- **{displayPathText}**: skipped (protected scope)") else: @@ -130,8 +145,7 @@ def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: rootItemName: str for rootItemName in os.listdir(tempDir): - itemNormalizedText: str = rootItemName.lower() - if itemNormalizedText in protectedElementsSet: + if isPathProtected(rootItemName): addReportEntry(f"- **{rootItemName}**: skipped (protected scope)") continue @@ -191,3 +205,10 @@ def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: buildvarsStatusText, pyprojectStatusText, ) + + hasFailuresBool: bool = ( + any("failed" in reportEntryItem for reportEntryItem in syncReportList) + or "failed" in buildvarsStatusText.lower() + or "failed" in pyprojectStatusText.lower() + ) + return not hasFailuresBool From c13f4db55c49605e80c9b3c155844c10f98b6012 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:37:35 +0200 Subject: [PATCH 29/52] fix(pyproject): improve URL mapping, dependency version parsing, and TOML multiline formatting - Map legacy `addon_sourceURL` / `addon_url` to `project.urls.Repository`. - Enhance dependency version parsing and replacement mapping (pre-commit -> prek). - Enforce multiline layout and formatting for TOML array structures. --- syncAddonTool/pyproject.py | 62 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index ee13b44..b8b40ce 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -49,7 +49,9 @@ def createPyprojectFromTemplate(templateFilePath: Path, metadataDict: dict[str, if "addon_summary" in metadataDict and metadataDict["addon_summary"]: projectSection["description"] = metadataDict["addon_summary"] - addonUrlText: str = str(metadataDict.get("addon_url", "")).strip() + addonUrlText: str = str( + metadataDict.get("addon_sourceURL") or metadataDict.get("addon_url") or "" + ).strip() if addonUrlText: if "urls" not in projectSection: projectSection["urls"] = tomlkit.table() @@ -58,6 +60,15 @@ def createPyprojectFromTemplate(templateFilePath: Path, metadataDict: dict[str, if "addon_author" in metadataDict and metadataDict["addon_author"]: projectSection["maintainers"] = formatAuthorList(metadataDict["addon_author"]) + # Ensure that [tool.setuptools] is always present at creation --- + if "tool" not in tomlDoc: + tomlDoc["tool"] = tomlkit.table() + + if "setuptools" not in tomlDoc["tool"]: + setuptoolsTable = tomlkit.table() + setuptoolsTable["py-modules"] = tomlkit.array() + tomlDoc["tool"]["setuptools"] = setuptoolsTable + return tomlDoc @@ -406,11 +417,41 @@ def mergePyprojectToml( if not wasOriginallyNvaccess: cleanupPlaceholderAuthors(projectSectionDict) + addonUrlText: str = str( + metadataDict.get("addon_sourceURL") or metadataDict.get("addon_url") or "" + ).strip() + if addonUrlText: + if "urls" not in projectSectionDict or not isinstance(projectSectionDict["urls"], MutableMapping): + projectSectionDict["urls"] = {} + if not projectSectionDict["urls"].get("Repository"): + projectSectionDict["urls"]["Repository"] = addonUrlText + finalDoc: tomlkit.TOMLDocument = tomlkit.document() sectionKey: str sectionVal: Any + # Copy primary sections in preferred order + if "build-system" in mergedDictData: + finalDoc["build-system"] = mergedDictData["build-system"] + + if "project" in mergedDictData: + finalDoc["project"] = mergedDictData["project"] + + # Insert dependency-groups immediately after the project section + if "dependency-groups" in mergedDictData: + if "project" in finalDoc: + insertKeyAfter( + finalDoc, + targetKey="project", + newKey="dependency-groups", + value=mergedDictData["dependency-groups"], + ) + else: + finalDoc["dependency-groups"] = mergedDictData["dependency-groups"] + + # Append any remaining sections (e.g. tool tables) for sectionKey, sectionVal in mergedDictData.items(): - finalDoc[sectionKey] = sectionVal + if sectionKey not in finalDoc: + finalDoc[sectionKey] = sectionVal if "project" in finalDoc: projectTable: Any = finalDoc["project"] @@ -474,8 +515,23 @@ def makeMultilineArray(containerObj: Any, keyName: str) -> None: makeMultilineArray(toolSection["pyright"], "exclude") makeMultilineArray(toolSection["pyright"], "extraPaths") + # Automatic control and injection of [tool.setuptools] --- + if "tool" not in finalDoc: + finalDoc["tool"] = tomlkit.table() + + toolTable: Any = finalDoc["tool"] + if "setuptools" not in toolTable: + toolTable["setuptools"] = tomlkit.table() + + setuptoolsTable: Any = toolTable["setuptools"] + if "py-modules" not in setuptoolsTable: + setuptoolsTable["py-modules"] = [] + logger.debug("Added missing [tool.setuptools] py-modules section to final document") + + makeMultilineArray(setuptoolsTable, "py-modules") + if not dryRun: - tomlFormattedText: str = fixTomlIndentation(tomlkit.dumps(finalDoc)) + tomlFormattedText: str = fixTomlIndentation(tomlkit.dumps(finalDoc)).lstrip() with projectPathObj.open("w", encoding="utf-8") as f: f.write(tomlFormattedText) return "merged intelligently (tomlkit)" From 268001f77bdcf5e1ba7b7f5dc36d99d768f6cec5 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:40:29 +0200 Subject: [PATCH 30/52] fix(cli): ensure nonzero exit status on execution or backup failures Return exit status code 1 across all failure branches in CLI orchestration to properly fail CI pipelines and script invocations. --- syncAddonTool/cli.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/syncAddonTool/cli.py b/syncAddonTool/cli.py index 817a558..6d84106 100644 --- a/syncAddonTool/cli.py +++ b/syncAddonTool/cli.py @@ -141,7 +141,7 @@ def main() -> None: if sys.stdin.isatty(): input("\nPress Enter to exit...") sys.exit(1) - runSynchronization(templatePath, addonDir, parsedArgs.dryRun) + syncSuccess: bool = runSynchronization(templatePath, addonDir, parsedArgs.dryRun) else: logger.info("Phase 3: Provisioning latest official NVDA AddonTemplate via Git...") with tempfile.TemporaryDirectory() as tempDir: @@ -164,7 +164,13 @@ def main() -> None: input("\nPress Enter to exit...") sys.exit(1) - runSynchronization(tempDir, addonDir, parsedArgs.dryRun) + syncSuccess = runSynchronization(tempDir, addonDir, parsedArgs.dryRun) + + if not syncSuccess: + logger.error("Update process completed with errors. Check logs above.") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) if not parsedArgs.dryRun: logger.info("Project successfully updated. Workspace cleared.") From e3a42c7044bdfad433e5398169ed6ef441d4fdf5 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:43:46 +0200 Subject: [PATCH 31/52] docs(syncAddonTool): explain --no-project flag utility for running outside project root Document how `uv run --no-project --with tomlkit` allows executing syncAddonTool without initializing a workspace or installing project dependencies locally. --- .../updatingExistingAddons.md | 68 ++++++++----------- 1 file changed, 27 insertions(+), 41 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index c9fa647..f39e064 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -109,14 +109,26 @@ Before running the tool, ensure your system meets the following requirements: - **Git**: Git must be installed and available in your system `PATH`. -- For add-ons without a pyproject.toml file, **Dependency Management (tomlkit)**: - Because the automated script relies on the third-party `tomlkit` library to safely parse and merge configurations, it must be available in the Python environment used to run the script (either installed in the environment, or provided temporarily via `uv run --with tomlkit`). +- **Dependency Management (`tomlkit`)**: + The synchronization engine relies on `tomlkit` to safely parse and update `pyproject.toml`. + You have two options to make this dependency available: + + - **On-the-fly via global cache (Recommended):** + Pass `--with tomlkit` along with `--no-project` to `uv run`. + `uv` automatically downloads `tomlkit` into its global cache (`%LocalAppData%\uv\cache` on Windows) and reuses it for all future executions without modifying your local environment. + - **Permanent Global Installation:** + If you prefer to make `tomlkit` permanently available inside `uv`'s managed Python environment, run the following command: + ```sh + uv pip install --python 3.13 tomlkit + ``` + This command installs `tomlkit` directly into the `site-packages` directory of `uv`'s central Python 3.13 installation. + Once installed globally, you can execute the tool using `uv run --no-project python syncAddonTool` without needing to append `--with tomlkit` to every command. > [!IMPORTANT] > **Project Structure & Execution Methods:** > The update engine is structured as a Python package that relies on the `syncAddonTool/` directory layout. -> You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run python syncAddonTool`. -> Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run python -m syncAddonTool -ad /path/to/my-nvda-addon`. +> You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run --no-project --with tomlkit python syncAddonTool`. +> Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run --no-project --with tomlkit python -m syncAddonTool -ad /path/to/my-nvda-addon`. > Finally, you can use the standalone executable (`syncAddonTool.exe`), which requires no Python dependencies. ### Running the automated tool @@ -126,16 +138,14 @@ The tool is highly flexible and supports two execution modes: 1. **Standard Mode (No arguments):** Run the tool 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 syncAddonTool + uv run --no-project --with tomlkit python syncAddonTool ``` 2. **Target Directory Mode (With argument):** Run the tool 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 syncAddonTool -ad ../MyAddon + uv run --no-project --with tomlkit python syncAddonTool -ad ../MyAddon ``` > [!NOTE] @@ -240,7 +250,7 @@ addon/doc/fr/custom-extra-help.html 3. **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 tool 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). + Since the tool automatically normalizes all inputs to lowercase during execution, **you can write your rules using any casing you prefer** (e.g., `syncAddonTool` or `syncaddontool` will both work perfectly). 4. **File Location Requirement:** The update engine always loads custom exclusions from the target add-on's root folder being updated. @@ -257,13 +267,13 @@ Downloads the latest remote template, creates a safety backup of your repository - **Syntax A (Directory execution inside the add-on repository)**: ```sh - uv run python syncAddonTool + uv run --no-project --with tomlkit python syncAddonTool ``` - **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python syncAddonTool -ad /path/to/my-nvda-addon + uv run --no-project --with tomlkit python syncAddonTool -ad /path/to/my-nvda-addon ``` - **Syntax C (Standalone executable)**: @@ -279,13 +289,13 @@ Useful when testing local modifications applied to `AddonTemplate` or when worki - **Syntax A (Directory execution inside the add-on repository)**: ```sh - uv run python syncAddonTool -td /path/to/local/AddonTemplate + uv run --no-project --with tomlkit python syncAddonTool -td /path/to/local/AddonTemplate ``` - **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate + uv run --no-project --with tomlkit python syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate ``` - **Syntax C (Standalone executable)**: @@ -301,13 +311,13 @@ Analyzes structural layouts, evaluates configurations, reads `.addonmergeignore` - **Syntax A (Directory execution inside the add-on repository)**: ```sh - uv run python syncAddonTool --dry-run + uv run --no-project --with tomlkit python syncAddonTool --dry-run ``` - **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python syncAddonTool --dry-run -ad /path/to/my-nvda-addon + uv run --no-project --with tomlkit python syncAddonTool --dry-run -ad /path/to/my-nvda-addon ``` - **Syntax C (Standalone executable)**: @@ -323,13 +333,13 @@ Targets a project repository while skipping the automated safety backup creation - **Syntax A (Directory execution inside the add-on repository)**: ```sh - uv run python syncAddonTool --skip-backup + uv run --no-project --with tomlkit python syncAddonTool --skip-backup ``` - **Syntax B (Targeting an external add-on directory)**: ```sh - uv run python syncAddonTool -ad /path/to/my-nvda-addon --skip-backup + uv run --no-project --with tomlkit python syncAddonTool -ad /path/to/my-nvda-addon --skip-backup ``` - **Syntax C (Standalone executable)**: @@ -338,30 +348,6 @@ Targets a project repository while skipping the automated safety backup creation syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup ``` -##### 5. Run without Prior Installation (`--with` option) - -If you wish to execute the synchronization tool without installing its required third-party dependencies (like `tomlkit`) into your active environment beforehand, you can request `uv` to expose them temporarily during command execution: - -- **Using directory execution with `uv`**: - - ```sh - uv run --with tomlkit python syncAddonTool - ``` - -- **Using module execution with `uv`**: - - ```sh - uv run --with tomlkit python -m syncAddonTool - ``` - -- **Using Standalone Executable**: - - *(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* - - ```cmd - syncAddonTool.exe - ``` - --- ## Alternative Method: Manual Update Using Git Merge From 8dc4795011331131c10e58c5a0c2979067a71a30 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 1 Oct 2026 23:51:46 +0200 Subject: [PATCH 32/52] buildVarsSync: preserve quote style during merge and add unit test - Update `mergeBuildvarsFile` to detect original quote styles (single or double) via `ast.get_source_segment` and apply `formatStringLiteral` to retain template formatting. - Add `testMergeBuildvarsPreservesQuoteStyleFromFixture` in `test_syncAddonTool.py` to verify quote style preservation against template fixtures. --- syncAddonTool/buildVarsSync.py | 41 +++++++++++++++++++++-- tests/unit/template/test_syncAddonTool.py | 35 +++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 3814356..5a05395 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -14,6 +14,26 @@ logger: logging.Logger = logging.getLogger("syncAddon") +def formatStringLiteral(val: str, useDoubleQuotes: bool = False, isTranslatable: bool = False) -> str: + """Format a string literal preserving either double or single quotes. + + :param val: The string value to format. + :param useDoubleQuotes: If True, format using double quotes; otherwise default to single quotes. + :param isTranslatable: If True, wrap the formatted string in the gettext `_()` call. + :return: The formatted string literal representation, optionally wrapped in `_()`. + """ + formattedText: str + if useDoubleQuotes: + # Escape backslashes and internal double quotes if needed + escapedValText: str = val.replace("\\", "\\\\").replace('"', '\\"') + formattedText = f'"{escapedValText}"' + else: + # Default style using repr (single quotes) + formattedText = repr(val) + + return f"_({formattedText})" if isTranslatable else formattedText + + 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. @@ -57,11 +77,16 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict elif isinstance(astNodeItem.value, ast.Call) and getattr(astNodeItem.value.func, "id", None) == "AddonInfo": metadataDict.update(parseAstKeywords(astNodeItem.value.keywords)) elif varNameText in topLevelVarsSet: - globalVarsDict[varNameText] = (astNodeItem.value, ast.unparse(astNodeItem.value)) + # Preserve raw source segment to maintain original quote formatting + rawSegmentText: str | None = ast.get_source_segment(tplContentText if 'tplContentText' in locals() else f.read(), astNodeItem.value) if False else None + # Note: Read source directly or fallback to ast.unparse + sourceSegmentText: str = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) + globalVarsDict[varNameText] = (astNodeItem.value, sourceSegmentText) elif isinstance(astNodeItem, ast.AnnAssign): if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in topLevelVarsSet: if astNodeItem.value is not None: - globalVarsDict[astNodeItem.target.id] = (astNodeItem.value, ast.unparse(astNodeItem.value)) + sourceSegmentText = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) + globalVarsDict[astNodeItem.target.id] = (astNodeItem.value, sourceSegmentText) return metadataDict, globalVarsDict @@ -113,7 +138,17 @@ def mergeBuildvarsFile( formattedValueText = "None" elif isinstance(valueVal, str): isTranslatable: bool = keyName in ["addon_summary", "addon_description", "addon_changelog"] - formattedValueText = f"_({valueVal!r})" if isTranslatable else repr(valueVal) + + # Inspect source segment to detect original quote style (double vs single) + rawSourceText: str = ast.get_source_segment(tplContentText, kwItem.value) or "" + cleanedSourceText: str = rawSourceText.strip() + useDoubleQuotesBool: bool = cleanedSourceText.startswith('"') or cleanedSourceText.startswith('_("') + + formattedValueText = formatStringLiteral( + valueVal, + useDoubleQuotes=useDoubleQuotesBool, + isTranslatable=isTranslatable, + ) else: formattedValueText = str(valueVal) diff --git a/tests/unit/template/test_syncAddonTool.py b/tests/unit/template/test_syncAddonTool.py index 890f0bf..4442623 100644 --- a/tests/unit/template/test_syncAddonTool.py +++ b/tests/unit/template/test_syncAddonTool.py @@ -65,8 +65,8 @@ def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: content: str = projBvPath.read_text(encoding="utf-8") # Verify metadata mapping from fixture - self.assertIn("addon_name='myAddon'", content) - self.assertIn("addon_version='1.0.0'", content) + self.assertIn('addon_name="myAddon"', content) + self.assertIn('addon_version="1.0.0"', content) # Verify new official template imports self.assertIn("from site_scons.site_tools.NVDATool.typings import", content) @@ -94,9 +94,38 @@ def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: self.assertEqual(status, "merged & structured (AST verified)") content: str = projBvPath.read_text(encoding="utf-8") - self.assertIn("addon_name='myAddon'", content) + self.assertIn('addon_name="myAddon"', content) self.assertIn("SpeechDictionaries", content) + def testMergeBuildvarsPreservesQuoteStyleFromFixture(self) -> None: + """Ensure mergeBuildvarsFile preserves single or double quote style based on the template fixture.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # 1. Load legacy and template buildVars fixtures + legacyFixture: Path = FIXTURES_DIR / "legacyBuildVars.py" + projBvPath.write_text(legacyFixture.read_text(encoding="utf-8"), encoding="utf-8") + + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + # 2. Extract metadata and merge template into project file + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status: str = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content: str = projBvPath.read_text(encoding="utf-8") + + # 3. Verify quote style preservation based on templateBuildVars.py + self.assertIn('addon_name="myAddon"', content) + self.assertIn('addon_summary=_("My Test Addon")', content) + def testSetupAddonMergeIgnore(self) -> None: """Verify bootstrapping of .addonmergeignore from template to add-on directory. From 27e428aabadc1772346eb3b174f7a404b0f4d600 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 21:28:39 +0200 Subject: [PATCH 33/52] Docs(updatingExistingAddons.md): replace AddonTemplate with camelCase addonTemplate --- 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 530f354..4ad1603 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -45,7 +45,7 @@ You can merge the latest template changes into your repository instead of manual *This document explains the update procedures, including both the recommended automated method using `syncAddonTool` and the manual Git merge 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 From a571d30d4420721392d33c89302f54470e16dcf7 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 21:32:44 +0200 Subject: [PATCH 34/52] Docs(updatingExistingAddons.md): capitalize Chore in example commit message --- 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 4ad1603..6d36b69 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -164,7 +164,7 @@ If everything builds successfully, remove the `_bak_` director ```sh git clean -f git add . -git commit -m "chore: sync infrastructure with AddonTemplate" +git commit -m "Chore: sync infrastructure with AddonTemplate" ``` ### Using the Update Tool via Command Line From 1666570c7d48b601c93e31cdb4a06bda096f8407 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 21:35:49 +0200 Subject: [PATCH 35/52] Docs(updatingExistingAddons.md): clarify executable usage instructions --- 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 6d36b69..815b956 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -198,7 +198,7 @@ The compiled executable will be generated inside the `dist/` folder (`dist/syncA ##### 2. Running the Executable -Once compiled or downloaded, `syncAddonTool.exe` accepts the exact same command-line flags (`-ad`, `-td`, `--dry-run`, `--skip-backup`) as the Python execution modes: +Once compiled, `syncAddonTool.exe` accepts the exact same command-line flags (`-ad`, `-td`, `--dry-run`, `--skip-backup`) as the Python execution modes: - **Targeting an add-on directory from anywhere**: From 5daf1d7d4ef4539b723efc75b87912c2830c2f96 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 21:49:48 +0200 Subject: [PATCH 36/52] Docs(updatingExistingAddons.md): replace GitHub callout and clean formatting for screen readers --- docs/managementFromGit/updatingExistingAddons.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 815b956..e0c6a07 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -44,8 +44,8 @@ You can merge the latest template changes into your repository instead of manual *This document explains the update procedures, including both the recommended automated method using `syncAddonTool` and the manual Git merge 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. +> **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 @@ -124,8 +124,8 @@ Before running the tool, ensure your system meets the following requirements: This command installs `tomlkit` directly into the `site-packages` directory of `uv`'s central Python 3.13 installation. Once installed globally, you can execute the tool using `uv run --no-project python syncAddonTool` without needing to append `--with tomlkit` to every command. -> [!IMPORTANT] -> **Project Structure & Execution Methods:** +> **Important:** +> Project Structure & Execution Methods: > The update engine is structured as a Python package that relies on the `syncAddonTool/` directory layout. > You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run --no-project --with tomlkit python syncAddonTool`. > Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run --no-project --with tomlkit python -m syncAddonTool -ad /path/to/my-nvda-addon`. @@ -148,7 +148,7 @@ The tool is highly flexible and supports two execution modes: uv run --no-project --with tomlkit python syncAddonTool -ad ../MyAddon ``` -> [!NOTE] +> **Note:** > Before applying any modifications, the tool 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. From 2ed5002a3bb22f6c2e8c2c273f536f7f51af0ace Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 21:57:16 +0200 Subject: [PATCH 37/52] Docs(unitTesting.md): fix code block language syntax spaces --- docs/unitTesting.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/unitTesting.md b/docs/unitTesting.md index e6303d2..30c84ba 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -13,7 +13,7 @@ For unit tests to execute successfully, target modules (such as `syncAddonTool`) To run the entire unit test suite with automatic test discovery and detailed output for every executed test case: -``` bash +```bash uv run python -m unittest discover -s tests -v ``` @@ -28,7 +28,7 @@ Here is what each part of the command does: You can run individual test modules during development by specifying their path: * **Add-on Synchronization Tool Tests:** - ``` bash + ```bash uv run python -m unittest -v tests/unit/template/test_syncAddonTool.py ``` From abc1b28c1d1c3ab40a021ff137f5d84b0777e8ff Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 9 Oct 2026 22:08:13 +0200 Subject: [PATCH 38/52] Style(__main__.py): convert space indentation to tabs --- syncAddonTool/__main__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncAddonTool/__main__.py b/syncAddonTool/__main__.py index 829af95..a81bf23 100644 --- a/syncAddonTool/__main__.py +++ b/syncAddonTool/__main__.py @@ -12,9 +12,9 @@ PACKAGE_DIR = Path(__file__).resolve().parent REPO_ROOT = PACKAGE_DIR.parent if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) + sys.path.insert(0, str(REPO_ROOT)) from syncAddonTool.cli import main if __name__ == "__main__": - main() + main() From 0ea3b28c591245fa7839f7f9bf016fe753acf69e Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 14 Oct 2026 18:20:34 +0200 Subject: [PATCH 39/52] test(e2e): add E2E testing infrastructure, migrate buildVars tests, and update docs - Replace docs/unitTesting.md with docs/testing.md, consolidating documentation for both unit and E2E testing guides. - Introduce tests/e2e/template/test_syncAddonTool.py with 10 comprehensive E2E tests covering core CLI workflows, flags, options, and PyInstaller packaging. - Remove legacy and modern buildVars migration tests from unit tests as they are now fully covered by the E2E suite. --- docs/testing.md | 15 +- syncAddonTool/engine.py | 2 +- .../fixtures/addonTemplate/buildVars.py | 111 +++++ .../fixtures/addonTemplate/pyproject.toml | 236 ++++++++++ .../clipContentsDesigner/buildVars.py | 110 +++++ .../clipContentsDesigner/pyproject.toml | 228 ++++++++++ .../fixtures/dayOfTheWeek-legacy/buildVars.py | 79 ++++ .../fixtures/placeMarkers-modern/buildVars.py | 101 +++++ .../placeMarkers-modern/pyproject.toml | 212 +++++++++ tests/e2e/template/test_sanity.py | 31 -- tests/e2e/template/test_syncAddonTool.py | 405 ++++++++++++++++++ tests/unit/template/test_syncAddonTool.py | 57 --- 12 files changed, 1494 insertions(+), 93 deletions(-) create mode 100644 tests/e2e/template/fixtures/addonTemplate/buildVars.py create mode 100644 tests/e2e/template/fixtures/addonTemplate/pyproject.toml create mode 100644 tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py create mode 100644 tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml create mode 100644 tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py create mode 100644 tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py create mode 100644 tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml delete mode 100644 tests/e2e/template/test_sanity.py create mode 100644 tests/e2e/template/test_syncAddonTool.py diff --git a/docs/testing.md b/docs/testing.md index f38edf1..136ffde 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,22 +10,29 @@ To run the entire test suite (both unit and E2E tests) locally using `uv`: uv run python -m unittest discover -s tests -v ``` +Here is what each part of the command does: + +* `uv`: Executes the command within the virtual environment managed by `uv`. +* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. +* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. +* `-v`: Enables verbose output, displaying the status and description of each test method individually. + ## Unit Testing -Unit tests are located under the `tests/unit/` directory. +Unit tests are located under the `tests/unit/` directory to validate internal synchronization logic, AST modifications, and helpers. To execute tests for a specific unit test file: ```bash -uv run python -m unittest -v tests/unit/template/test_sanity.py +uv run python -m unittest -v tests/unit/template/test_syncAddonTool.py ``` ## End-to-End (E2E) Testing -End-to-end tests are located under the `tests/e2e/` directory to validate system-level workflows and CLI integration. +End-to-end tests are located under the `tests/e2e/` directory to validate system-level workflows, CLI interfaces, and real-world packaging. To execute tests for a specific E2E test file: ```bash -uv run python -m unittest -v tests/e2e/template/test_sanity.py +uv run python -m unittest -v tests/e2e/template/test_syncAddonTool.py ``` diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py index ce06e17..e4a91ce 100644 --- a/syncAddonTool/engine.py +++ b/syncAddonTool/engine.py @@ -54,7 +54,7 @@ def runSynchronization(tempDir: str, addonDir: str, dryRun: bool) -> bool: "readme.md", "changelog.md", "addontemplate.egg-info", - ".github/workflows/unittests.yml", + ".github/workflows/tests.yml", "addon", ".git", "__pycache__", diff --git a/tests/e2e/template/fixtures/addonTemplate/buildVars.py b/tests/e2e/template/fixtures/addonTemplate/buildVars.py new file mode 100644 index 0000000..fde02e7 --- /dev/null +++ b/tests/e2e/template/fixtures/addonTemplate/buildVars.py @@ -0,0 +1,111 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name="addonTemplate", + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_("Add-on user visible name"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_("""Description for the add-on. +It can span multiple lines."""), + # version + addon_version="x.y", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("""Changelog for the add-on version. +It can span multiple lines."""), + # Author(s) + addon_author="name ", + # URL for the add-on documentation support + addon_url=None, + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName="readme.html", + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion=None, + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion=None, + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = [] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +symbolDictionaries: SymbolDictionaries = {} + +# Custom speech dictionaries (distinct from symbol dictionaries above) +# Speech dictionary files reside in the speechDicts folder and are named `name.dic`. +# If your add-on includes custom speech (pronunciation) dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +speechDictionaries: SpeechDictionaries = {} diff --git a/tests/e2e/template/fixtures/addonTemplate/pyproject.toml b/tests/e2e/template/fixtures/addonTemplate/pyproject.toml new file mode 100644 index 0000000..ae2bdce --- /dev/null +++ b/tests/e2e/template/fixtures/addonTemplate/pyproject.toml @@ -0,0 +1,236 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +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 & repository synchronization machinery +build = [ + "scons==4.10.1", + "Markdown==3.10", + "tomlkit==0.15.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 = [ + "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" }, +] + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", + "syncAddonTool", + "tests", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + ".github/scripts", + "syncAddonTool", + "tests", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. + +[tool.setuptools] +py-modules = [] diff --git a/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py b/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py new file mode 100644 index 0000000..55faf01 --- /dev/null +++ b/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py @@ -0,0 +1,110 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name='clipContentsDesigner', + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_('Clip Contents Designer'), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_('Allows the joining of separate fragments of text on the clipboard and clearing of clipboard content. Current text on the clipboard can be presented in browse mode. Option to request confirmation before performing actions such as copy, cut, add text, or clearing the clipboard to avoid accidental changes.'), + # version + addon_version="52.2.0", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("""* Restored ability to check the clipboard content. +* Updates to translations."""), + # Author(s) + addon_author='Noelia , Abdel ', + # URL for the add-on documentation support + addon_url='https://github.com/nvdaes/clipContentsDesigner', + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName='readme.html', + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion='2026.1', + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion='2026.3', + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = ['addon/*.py', 'addon/globalPlugins/clipContentsDesigner/*.py'] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = 'en' + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +symbolDictionaries: SymbolDictionaries = {} + +# Custom speech dictionaries (distinct from symbol dictionaries above) +# Speech dictionary files reside in the speechDicts folder and are named `name.dic`. +# If your add-on includes custom speech (pronunciation) dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +speechDictionaries: SpeechDictionaries = {} diff --git a/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml b/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml new file mode 100644 index 0000000..b977f71 --- /dev/null +++ b/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml @@ -0,0 +1,228 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "clipContentsDesigner" +dynamic = ["version"] +description = "Clip Contents Designer" +maintainers = [ + {name = "Noelia", email = "nrm1977@gmail.com"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +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/nvdaes/clipContentsDesigner" + +# 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 +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 = [ + "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" }, +] + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + ".github/scripts", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. diff --git a/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py b/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py new file mode 100644 index 0000000..2ec9234 --- /dev/null +++ b/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py @@ -0,0 +1,79 @@ +# -*- coding: UTF-8 -*- + +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply roll our own "fake" `_` function +# which returns whatever is given to it as an argument. +def _(arg): + return arg + + +# Add-on information variables +addon_info = { + # add-on Name/identifier, internal for NVDA + "addon_name": "dayOfTheWeek", + # Add-on summary, usually the user visible name of the addon. + # Translators: Summary for this add-on + # to be shown on installation and add-on information found in Add-ons Manager. + "addon_summary": _("Day of the week"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-ons manager + "addon_description": _("""With this add-on, you'll be able to find the day of the week corresponding to a chosen date."""), + # version + "addon_version": "20251022.0.1", + # Author(s) + "addon_author": "Abdel , Noelia ", + # URL for the add-on documentation support + "addon_url": "https://github.com/abdel792/dayOfTheWeek", + # URL for the add-on repository where the source code can be found + "addon_sourceURL": "https://github.com/abdel792/dayOfTheWeek", + # Documentation file name + "addon_docFileName": "readme.html", + # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) + "addon_minimumNVDAVersion": "2019.3.0", + # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) + "addon_lastTestedNVDAVersion": "2026.1.0", + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + "addon_updateChannel": None, + # Add-on license such as GPL 2 + "addon_license": "GPL v2", + # URL for the license document the ad-on is licensed under + "addon_licenseURL": "https://www.gnu.org/licenses/gpl-2.0.html", +} + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +import os +pythonSources = [os.path.join("addon", "globalPlugins", "*.py")] + +# Files that contain strings for translation. Usually your python sources +i18nSources = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +excludedFiles = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +baseLanguage = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions = [] diff --git a/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py b/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py new file mode 100644 index 0000000..d126bdb --- /dev/null +++ b/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py @@ -0,0 +1,101 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name="placeMarkers", + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_("Place markers"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_("Add-on for setting place markers on specific virtual documents"), + # version + addon_version="50.0.0", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("* Compatible with NVDA 2026.1."), + # Author(s) + addon_author="Noelia , Chris ", + # URL for the add-on documentation support + addon_url="https://github.com/nvdaes/placeMarkers", + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName="readme.html", + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion="2026.1", + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion="2026.1", + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = ["addon/globalPlugins/*.py", "addon/globalPlugins/placeMarkers/*.py"] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not. +symbolDictionaries: SymbolDictionaries = {} diff --git a/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml b/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml new file mode 100644 index 0000000..aa8752d --- /dev/null +++ b/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml @@ -0,0 +1,212 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +dependencies = [ + # Build add-on + "scons==4.10.1", + "Markdown==3.10", + # Translations management + "requests==2.32.5", + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.0.2", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + # Lint + "uv==0.9.11", + "ruff==0.14.5", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", +] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. diff --git a/tests/e2e/template/test_sanity.py b/tests/e2e/template/test_sanity.py deleted file mode 100644 index 2d8bb51..0000000 --- a/tests/e2e/template/test_sanity.py +++ /dev/null @@ -1,31 +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. - -"""Sanity test suite for verifying end-to-end test execution.""" - -import tempfile -import unittest -from pathlib import Path - - -class TestE2ESanity(unittest.TestCase): - """Minimal sanity test suite to verify E2E test runner and environment setup.""" - - def test_e2e_runner_handles_passing_tests(self): - """Ensure that temporary directories and basic file operations work for E2E tests.""" - with tempfile.TemporaryDirectory() as temp_dir: - test_path = Path(temp_dir) / "fixture_test.txt" - test_path.write_text("E2E environment check", encoding="utf-8") - - self.assertTrue(test_path.exists()) - self.assertEqual(test_path.read_text(encoding="utf-8"), "E2E environment check") - - @unittest.expectedFailure - def test_e2e_runner_handles_failing_tests(self): - """Ensure that the E2E test runner correctly detects failing integration tests. - - Marked with @expectedFailure so CI remains green while demonstrating - failure detection in E2E tests. - """ - self.assertTrue(False) \ No newline at end of file diff --git a/tests/e2e/template/test_syncAddonTool.py b/tests/e2e/template/test_syncAddonTool.py new file mode 100644 index 0000000..579539d --- /dev/null +++ b/tests/e2e/template/test_syncAddonTool.py @@ -0,0 +1,405 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""End-to-end test suite for syncAddonTool CLI execution, modes, options, packaging, and buildVars migrations.""" + +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +FIXTURES_DIR: Path = Path(__file__).parent / "fixtures" + + +class TestSyncAddonToolE2E(unittest.TestCase): + """Test cases for verifying syncAddonTool CLI interface, synchronization modes, options, PyInstaller packaging, and buildVars migrations.""" + + def test_cli_help_output(self) -> None: + """Ensure the synchronization tool displays help instructions correctly via CLI using -h / --help.""" + helpFlag: str + for helpFlag in ["--help", "-h"]: + with self.subTest(flag=helpFlag): + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + helpFlag, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("--addon-dir", result.stdout) + self.assertIn("--template-dir", result.stdout) + self.assertIn("--dry-run", result.stdout) + self.assertIn("--skip-backup", result.stdout) + + def test_standard_mode_run_inside_addon_directory(self) -> None: + """Test standard execution mode: running the tool directly from inside clipContentsDesigner root without -ad.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + projectRoot: Path = Path(__file__).parents[3] + toolPackageSrc: Path = projectRoot / "syncAddonTool" + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + # Embed syncAddonTool package inside the addon directory to allow standalone execution without -ad + shutil.copytree(toolPackageSrc, addonPath / "syncAddonTool", dirs_exist_ok=True) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + ], + cwd=str(addonPath), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Standard mode failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + buildVarsFile: Path = addonPath / "buildVars.py" + if buildVarsFile.exists(): + content: str = buildVarsFile.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + + def test_target_directory_mode_with_external_ad(self) -> None: + """Test target directory mode: running the tool specifying -ad for clipContentsDesigner from an external workspace.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + projectRoot: Path = Path(__file__).parents[3] + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + "-ad", + str(addonPath), + ], + cwd=str(projectRoot), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"External -ad mode failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + buildVarsFile: Path = addonPath / "buildVars.py" + if buildVarsFile.exists(): + content: str = buildVarsFile.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + + def test_local_template_dir_with_td(self) -> None: + """Ensure the -td / --template-dir option successfully synchronizes using a local template reference.""" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not templateFixture.exists() or not addonFixture.exists(): + self.skipTest("Template or clipContentsDesigner add-on fixture not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(addonFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + + def test_dry_run_flag_dr(self) -> None: + """Ensure both --dry-run and short flag -dr simulate updates without altering files or creating backups.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + dryRunFlag: str + for dryRunFlag in ["--dry-run", "-dr"]: + with self.subTest(flag=dryRunFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + dryRunFlag, + "-ad", + str(addonPath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + backups: list[Path] = list(addonPath.parent.glob("*_bak_*")) + self.assertEqual(len(backups), 0) + + def test_skip_backup_flag_s(self) -> None: + """Ensure both --skip-backup and short flag -s prevent the creation of a timestamped backup directory.""" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not templateFixture.exists() or not addonFixture.exists(): + self.skipTest("Template or clipContentsDesigner add-on fixture not found.") + + skipBackupFlag: str + for skipBackupFlag in ["--skip-backup", "-s"]: + with self.subTest(flag=skipBackupFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(addonFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + skipBackupFlag, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + backups: list[Path] = list(addonPath.parent.glob("*_bak_*")) + self.assertEqual(len(backups), 0, f"Expected no backup directory when {skipBackupFlag} is passed") + + def test_verbose_logging_flag_v(self) -> None: + """Ensure both --verbose and short flag -v enable detailed debug logging in console output.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + verboseFlag: str + for verboseFlag in ["--verbose", "-v"]: + with self.subTest(flag=verboseFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + verboseFlag, + "-ad", + str(addonPath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + combinedOutput: str = result.stdout + result.stderr + self.assertIn("DEBUG", combinedOutput) + + def test_e2e_legacy_buildvars_migration(self) -> None: + """Ensure legacy buildVars.py from dayOfTheWeek-legacy fixture is correctly merged via CLI execution.""" + legacyFixture: Path = FIXTURES_DIR / "dayOfTheWeek-legacy" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + if not legacyFixture.exists() or not templateFixture.exists(): + self.skipTest("Fixture dayOfTheWeek-legacy or addonTemplate not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "dayOfTheWeek" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(legacyFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Legacy buildVars migration E2E failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + projBvPath: Path = addonPath / "buildVars.py" + self.assertTrue(projBvPath.exists()) + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + self.assertIn("addon_version", content) + + def test_e2e_modern_buildvars_speech_dictionaries(self) -> None: + """Ensure modern buildVars.py from placeMarkers-modern fixture gets missing speechDictionaries injected via CLI.""" + modernFixture: Path = FIXTURES_DIR / "placeMarkers-modern" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + if not modernFixture.exists() or not templateFixture.exists(): + self.skipTest("Fixture placeMarkers-modern or addonTemplate not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "placeMarkers" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(modernFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Modern buildVars speechDictionaries E2E failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + projBvPath: Path = addonPath / "buildVars.py" + self.assertTrue(projBvPath.exists()) + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("SpeechDictionaries", content) + + def test_pyinstaller_executable_build(self) -> None: + """Verify that the standalone executable can be successfully built using PyInstaller and uv.""" + specPath: Path = Path("syncAddonTool/syncAddonTool.spec") + if not specPath.exists(): + self.skipTest("syncAddonTool/syncAddonTool.spec not found in repository.") + + # Clean previous build and dist artifacts to force a fresh compilation + for dirName in ["build", "dist"]: + dirPath: Path = Path(dirName) + if dirPath.exists(): + shutil.rmtree(dirPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--with", + "pyinstaller", + "pyinstaller", + "syncAddonTool/syncAddonTool.spec", + "--noconfirm", + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, f"PyInstaller build failed: {result.stderr}") + + expectedExe: Path = Path("dist/syncAddonTool.exe") + self.assertTrue(expectedExe.exists(), "Expected dist/syncAddonTool.exe to be generated") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/template/test_syncAddonTool.py b/tests/unit/template/test_syncAddonTool.py index 4442623..ba21e03 100644 --- a/tests/unit/template/test_syncAddonTool.py +++ b/tests/unit/template/test_syncAddonTool.py @@ -40,63 +40,6 @@ def load_tests( class TestSyncAddonTool(unittest.TestCase): """Test cases for checking synchronization logic and file merges.""" - def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: - """Ensure legacy buildVars.py is correctly merged into the latest official template structure.""" - with tempfile.TemporaryDirectory() as tempDir: - projBvPath: Path = Path(tempDir) / "buildVars.py" - tplBvPath: Path = Path(tempDir) / "template_buildVars.py" - - # 1. Legacy buildVars fixture - legacyFixture: Path = FIXTURES_DIR / "legacyBuildVars.py" - projBvPath.write_text(legacyFixture.read_text(encoding="utf-8"), encoding="utf-8") - - # 2. Official template buildVars fixture - templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" - tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") - - metadata: dict - globalVars: dict - metadata, globalVars = extractBuildvarsMetadata(projBvPath) - status: str = mergeBuildvarsFile( - projBvPath, tplBvPath, metadata, globalVars, dryRun=False - ) - - self.assertEqual(status, "merged & structured (AST verified)") - - content: str = projBvPath.read_text(encoding="utf-8") - # Verify metadata mapping from fixture - self.assertIn('addon_name="myAddon"', content) - self.assertIn('addon_version="1.0.0"', content) - # Verify new official template imports - self.assertIn("from site_scons.site_tools.NVDATool.typings import", content) - - def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: - """Ensure modern buildVars.py gets missing speechDictionaries imported/injected from official template.""" - with tempfile.TemporaryDirectory() as tempDir: - projBvPath: Path = Path(tempDir) / "buildVars.py" - tplBvPath: Path = Path(tempDir) / "template_buildVars.py" - - # 1. Modern buildVars fixture (without SpeechDictionaries imported in original) - modernFixture: Path = FIXTURES_DIR / "modernBuildVars.py" - projBvPath.write_text(modernFixture.read_text(encoding="utf-8"), encoding="utf-8") - - # 2. Official template buildVars fixture - templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" - tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") - - metadata: dict - globalVars: dict - metadata, globalVars = extractBuildvarsMetadata(projBvPath) - status: str = mergeBuildvarsFile( - projBvPath, tplBvPath, metadata, globalVars, dryRun=False - ) - - self.assertEqual(status, "merged & structured (AST verified)") - - content: str = projBvPath.read_text(encoding="utf-8") - self.assertIn('addon_name="myAddon"', content) - self.assertIn("SpeechDictionaries", content) - def testMergeBuildvarsPreservesQuoteStyleFromFixture(self) -> None: """Ensure mergeBuildvarsFile preserves single or double quote style based on the template fixture.""" with tempfile.TemporaryDirectory() as tempDir: From 8dbbe52a9bcb0d1a4fdde9d144d86582d5d8f21b Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Wed, 14 Oct 2026 18:57:11 +0200 Subject: [PATCH 40/52] ci(workflow): switch test runner from ubuntu-latest to windows-latest - Update .github/workflows/tests.yml to run on windows-latest. - Ensure PyInstaller can correctly build and validate the Windows executable (.exe) within the CI environment. --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a65641..f38c1d6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ on: jobs: run-tests: name: Run Test Suite (Unit & E2E) - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout repository From c21773b04557571e7bfe6cd2c7ba794f5e3a0c69 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 15 Oct 2026 19:00:07 +0200 Subject: [PATCH 41/52] fix(buildVarsSync): escape control characters in double-quoted literals - Use unicode_escape encoding in formatStringLiteral to correctly handle newlines and control characters inside double-quoted string literals. --- syncAddonTool/buildVarsSync.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 5a05395..ad76b14 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -24,8 +24,8 @@ def formatStringLiteral(val: str, useDoubleQuotes: bool = False, isTranslatable: """ formattedText: str if useDoubleQuotes: - # Escape backslashes and internal double quotes if needed - escapedValText: str = val.replace("\\", "\\\\").replace('"', '\\"') + # Escape control characters, backslashes, and double quotes. + escapedValText: str = val.encode("unicode_escape").decode("ascii").replace('"', '\\"') formattedText = f'"{escapedValText}"' else: # Default style using repr (single quotes) From 3bdaed6e8c087b1fcab7865dcfe3095e4ab23daa Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 15 Oct 2026 20:05:41 +0200 Subject: [PATCH 42/52] fix(buildVarsSync): handle annotated addon_info assignments - Update AnnAssign AST node processing in parseBuildvars to check for addon_info. - Support extraction of metadata from both annotated dictionary assignments and annotated AddonInfo call keywords. --- syncAddonTool/buildVarsSync.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index ad76b14..30cafe8 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -83,7 +83,12 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict sourceSegmentText: str = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) globalVarsDict[varNameText] = (astNodeItem.value, sourceSegmentText) elif isinstance(astNodeItem, ast.AnnAssign): - if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in topLevelVarsSet: + if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id == "addon_info": + if isinstance(astNodeItem.value, ast.Dict): + metadataDict.update(parseAstDict(astNodeItem.value)) + elif isinstance(astNodeItem.value, ast.Call) and getattr(astNodeItem.value.func, "id", None) == "AddonInfo": + metadataDict.update(parseAstKeywords(astNodeItem.value.keywords)) + elif isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in topLevelVarsSet: if astNodeItem.value is not None: sourceSegmentText = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) globalVarsDict[astNodeItem.target.id] = (astNodeItem.value, sourceSegmentText) From caff4a72fc85b61e1db3d8ac820046a5aa238be8 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 15 Oct 2026 21:34:31 +0200 Subject: [PATCH 43/52] fix(buildVarsSync): preserve non-literal metadata expressions - Check if AddonInfo keyword values are AST constants before formatting. - Retain original source expressions (such as variables or function calls) without forcing string literals or quotation marks. --- syncAddonTool/buildVarsSync.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 30cafe8..500fd3f 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -141,21 +141,22 @@ def mergeBuildvarsFile( formattedValueText: str if valueVal is None: formattedValueText = "None" - elif isinstance(valueVal, str): + elif isinstance(kwItem.value, ast.Constant): + # Handle direct literal values (strings, numbers, booleans) isTranslatable: bool = keyName in ["addon_summary", "addon_description", "addon_changelog"] - # Inspect source segment to detect original quote style (double vs single) rawSourceText: str = ast.get_source_segment(tplContentText, kwItem.value) or "" cleanedSourceText: str = rawSourceText.strip() useDoubleQuotesBool: bool = cleanedSourceText.startswith('"') or cleanedSourceText.startswith('_("') formattedValueText = formatStringLiteral( - valueVal, + str(valueVal), useDoubleQuotes=useDoubleQuotesBool, isTranslatable=isTranslatable, ) else: - formattedValueText = str(valueVal) + # Preserve non-literal expressions (e.g. variables or function calls) without forcing quotes + formattedValueText = ast.get_source_segment(tplContentText, kwItem.value) or ast.unparse(kwItem.value) if kwItem.end_lineno is not None: lineContentText: str = templateLineList[kwItem.lineno - 1] From d7d083059ef5aec5d4bbaa0530402c7b065fdd95 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 15 Oct 2026 23:08:08 +0200 Subject: [PATCH 44/52] fix(buildVarsSync): preserve required imports for global expressions - Add usesGlobModule helper in astUtils.py to detect glob references in extracted global expressions. - Automatically inject 'from glob import glob' into generated buildVars.py when required to prevent NameError. - Add testMergeBuildvarsAutoImportsGlob unit test to verify automatic import injection. --- syncAddonTool/astUtils.py | 15 ++++++++++++++ syncAddonTool/buildVarsSync.py | 17 +++++++++++++-- tests/unit/template/test_syncAddonTool.py | 25 +++++++++++++++++++++++ 3 files changed, 55 insertions(+), 2 deletions(-) diff --git a/syncAddonTool/astUtils.py b/syncAddonTool/astUtils.py index c4a3fc5..20b690f 100644 --- a/syncAddonTool/astUtils.py +++ b/syncAddonTool/astUtils.py @@ -67,6 +67,21 @@ def usesOsModule(astNode: ast.AST) -> bool: return False +def usesGlobModule(astNode: ast.AST) -> bool: + """Check recursively if an AST node references 'glob'. + + :param astNode: The AST node to inspect. + :return: True if the node references 'glob', False otherwise. + """ + childNode: ast.AST + for childNode in ast.walk(astNode): + if isinstance(childNode, ast.Name) and childNode.id == "glob": + return True + if isinstance(childNode, ast.Call) and getattr(childNode.func, "id", None) == "glob": + return True + return False + + def replaceAstRange(templateLineList: list[str], replacementMap: dict[tuple[int, int], str]) -> None: """Apply AST line replacements on a line-by-line list in reverse order. diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 500fd3f..1a0ce72 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -from .astUtils import parseAstDict, parseAstKeywords, replaceAstRange, usesOsModule +from .astUtils import parseAstDict, parseAstKeywords, replaceAstRange, usesOsModule, usesGlobModule logger: logging.Logger = logging.getLogger("syncAddon") @@ -112,6 +112,9 @@ def mergeBuildvarsFile( :param dryRun: If True, simulate the merge without writing changes to disk. :return: A string indicating the result of the merge operation. """ + requiresOsImport: bool = False + requiresGlobImport: bool = False + templatePathObj: Path = Path(tplFilePath) projectPathObj: Path = Path(projFilePath) @@ -128,7 +131,6 @@ def mergeBuildvarsFile( templateLineList: list[str] = tplContentText.splitlines(keepends=True) replacementMap: dict[tuple[int, int], str] = {} - requiresOsImport: bool = False astNodeItem: ast.AST for astNodeItem in ast.walk(parsedTree): @@ -174,6 +176,8 @@ def mergeBuildvarsFile( valAstNode, valExprText = globalVarsDict[keyName] if usesOsModule(valAstNode): requiresOsImport = True + if usesGlobModule(valAstNode): + requiresGlobImport = True if astNodeItem.end_lineno is not None: lineContentText = templateLineList[astNodeItem.lineno - 1] indentText = lineContentText[: len(lineContentText) - len(lineContentText.lstrip())] @@ -187,6 +191,8 @@ def mergeBuildvarsFile( valAstNode, valExprText = globalVarsDict[keyName] if usesOsModule(valAstNode): requiresOsImport = True + if usesGlobModule(valAstNode): + requiresGlobImport = True if astNodeItem.end_lineno is not None: lineContentText = templateLineList[astNodeItem.lineno - 1] indentText = lineContentText[: len(lineContentText) - len(lineContentText.lstrip())] @@ -202,6 +208,13 @@ def mergeBuildvarsFile( if not hasOsImport: templateLineList.insert(0, "import os\n") + if requiresGlobImport: + hasGlobImport: bool = any("from glob import glob" in line or "import glob" in line for line in templateLineList[:15]) + if not hasGlobImport: + # Insert glob import right after os import or at the top + insertIndex: int = 1 if any("import os" in line for line in templateLineList[:2]) else 0 + templateLineList.insert(insertIndex, "from glob import glob\n") + if not dryRun: with projectPathObj.open("w", encoding="utf-8") as f: f.writelines(templateLineList) diff --git a/tests/unit/template/test_syncAddonTool.py b/tests/unit/template/test_syncAddonTool.py index ba21e03..a44ed49 100644 --- a/tests/unit/template/test_syncAddonTool.py +++ b/tests/unit/template/test_syncAddonTool.py @@ -177,6 +177,31 @@ def testMergeBuildvarsAutoImportsOs(self) -> None: content: str = projBvPath.read_text(encoding="utf-8") self.assertTrue(content.startswith("import os\n")) + def testMergeBuildvarsAutoImportsGlob(self) -> None: + """Ensure 'from glob import glob' is automatically added if merged buildVars uses the glob module.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # Legacy buildVars using glob function without import in template + projBvPath.write_text( + 'pythonSources = glob("*.py")\n', + encoding="utf-8", + ) + + tplBvPath.write_text( + 'pythonSources: list[str] = []\n', + encoding="utf-8", + ) + + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) + + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("from glob import glob", content) + def testFixTomlIndentation(self) -> None: """Ensure 4-space indentations are converted to tabs across TOML blocks.""" inputToml: str = ( From 44107a329a172d1845a6a02a962e3fba0fb39a41 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 08:25:32 +0200 Subject: [PATCH 45/52] fix(engine): honor custom exclusions during configuration merges - Guard buildVars.py and pyproject.toml merges with isPathProtected checks. - Report excluded configuration files as skipped (protected scope) when matched in .addonmergeignore. --- syncAddonTool/engine.py | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py index e4a91ce..165f301 100644 --- a/syncAddonTool/engine.py +++ b/syncAddonTool/engine.py @@ -178,18 +178,27 @@ def inspectAndCopyDirectory(srcDirPath: str, dstDirPath: str) -> None: buildvarsMetadataDict, buildvarsGlobalsDict = extractBuildvarsMetadata(oldBuildvarsPath) addonNameVal: Any = buildvarsMetadataDict.get("addon_name", os.path.basename(addonDir)) - buildvarsStatusText: str = mergeBuildvarsFile( - oldBuildvarsPath, - templateBuildvarsPath, - buildvarsMetadataDict, - buildvarsGlobalsDict, - dryRun, + # Honor .addonmergeignore rules for structural configuration files as well + buildvarsStatusText: str = ( + "skipped (protected scope)" + if isPathProtected("buildVars.py") + else mergeBuildvarsFile( + oldBuildvarsPath, + templateBuildvarsPath, + buildvarsMetadataDict, + buildvarsGlobalsDict, + dryRun, + ) ) - pyprojectStatusText: str = mergePyprojectToml( - oldPyprojectPath, - templatePyprojectPath, - buildvarsMetadataDict, - dryRun, + pyprojectStatusText: str = ( + "skipped (protected scope)" + if isPathProtected("pyproject.toml") + else mergePyprojectToml( + oldPyprojectPath, + templatePyprojectPath, + buildvarsMetadataDict, + dryRun, + ) ) logger.info("=" * 50) From e83a9e25147345bf74b76a20c9985185ba47a0db Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 21:14:25 +0200 Subject: [PATCH 46/52] fix(pyproject): restrict requirement matching to dependency arrays - Update mergeDependencyLists to check the contextName and bypass package-name parsing for non-dependency arrays (such as classifiers, include, and exclude paths). - Apply a safe value-based union and preservation policy for non-dependency lists to prevent unintended truncation or overriding of values like project classifiers. --- syncAddonTool/pyproject.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index b8b40ce..2c1be33 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -88,7 +88,23 @@ def mergeDependencyLists( :param contextName: Optional key/section name for logging context. :return: A merged list with updated versions and preserved custom or newer user items. """ - isActualDependencyList: bool = contextName in ["dependencies", "dependency-groups"] + isActualDependencyList: bool = ( + not contextName + or contextName == "dependencies" + or contextName.endswith(".dependencies") + or "dependency-groups" in contextName + ) + + # Restrict requirement/package matching strictly to dependency arrays + if not isActualDependencyList: + # For non-dependency arrays (e.g. classifiers, include, exclude, paths), + # apply an explicit preservation and complete-value union policy without package parsing. + mergedList: list[Any] = list(projList) + for item in tplList: + if item not in mergedList: + mergedList.append(item) + return mergedList + # Only emit decision logs if processing dependency-groups, preventing duplicate # or premature decision logs on flat project.dependencies before migration/purge. shouldLogDecisions: bool = contextName != "dependencies" and isActualDependencyList From 23053bf0596f1be054c7a415c1aa989b32cdc300 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 22:15:51 +0200 Subject: [PATCH 47/52] fix(pyproject): purge static fields from merged dynamic metadata - Filter out fields from project.dynamic if they are already statically defined in the project metadata section, ensuring strict compliance with PEP 621 packaging rules. - Prevent build failures caused by conflicting static and dynamic declarations during template synchronization. --- syncAddonTool/pyproject.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index 2c1be33..ea72694 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -424,7 +424,15 @@ def mergePyprojectToml( mergedDictData: dict[str, Any] = deepMergeDicts( cast(dict[str, Any], projTomlData), cast(dict[str, Any], tplTomlData) ) - + # Remove static metadata fields from the merged dynamic list to comply with PEP 621 + projectMetadata: Any = mergedDictData.get("project") + if isinstance(projectMetadata, MutableMapping) and "dynamic" in projectMetadata: + projectMetadata["dynamic"] = [ + fieldName for fieldName in projectMetadata["dynamic"] + if fieldName not in projectMetadata + ] + if not projectMetadata["dynamic"]: + del projectMetadata["dynamic"] processDependencyGroupsMigration(mergedDictData, userFlatDepsList) if "project" in mergedDictData: From 03e67720a8602d13c203ebc097200061a37fe763 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 22:24:51 +0200 Subject: [PATCH 48/52] fix(buildVarsSync): preserve i18nSources during build variable extraction - Add "i18nSources" to topLevelVarsSet in extractBuildvarsMetadata to prevent template defaults from overwriting custom translation sources. --- syncAddonTool/buildVarsSync.py | 1 + 1 file changed, 1 insertion(+) diff --git a/syncAddonTool/buildVarsSync.py b/syncAddonTool/buildVarsSync.py index 1a0ce72..86ee8b7 100644 --- a/syncAddonTool/buildVarsSync.py +++ b/syncAddonTool/buildVarsSync.py @@ -55,6 +55,7 @@ def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict globalVarsDict: dict[str, tuple[ast.AST, str]] = {} topLevelVarsSet: set[str] = { "pythonSources", + "i18nSources", "excludedFiles", "baseLanguage", "markdownExtensions", From f0586d71c89055dc9303713910d019b26b1dcb07 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 22:56:33 +0200 Subject: [PATCH 49/52] fix(engine): match directory exclusions with trailing separators - Strip trailing separators when matching directory-only exclusion rules to ensure directories like build/ are properly recognized and excluded. - Prevent directory-only patterns from matching regular files. --- syncAddonTool/engine.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py index 165f301..fe52543 100644 --- a/syncAddonTool/engine.py +++ b/syncAddonTool/engine.py @@ -95,8 +95,13 @@ def isPathProtected(relPathText: str) -> bool: :return: True if the path matches a protected element or pattern, False otherwise. """ pathNormalizedText: str = relPathText.replace("\\", "/").lower() + isDirectory: bool = os.path.isdir(os.path.join(tempDir, relPathText)) patternItem: str for patternItem in protectedElementsSet: + if patternItem.endswith("/"): + if not isDirectory: + continue + patternItem = patternItem.rstrip("/") if pathNormalizedText == patternItem or fnmatch.fnmatch(pathNormalizedText, patternItem): return True if fnmatch.fnmatch(os.path.basename(pathNormalizedText), patternItem): From 5afd6a770ac3de0791da3f0f23ce522711cbdcd9 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Fri, 16 Oct 2026 23:38:30 +0200 Subject: [PATCH 50/52] fix(pyproject): populate repository metadata before merging template defaults - Inject repository URL from buildVars.py into project metadata before executing deepMergeDicts. - Prevent template default repository URLs from incorrectly overwriting custom add-on repository URLs when project.urls is initially missing. --- syncAddonTool/pyproject.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py index ea72694..d412067 100644 --- a/syncAddonTool/pyproject.py +++ b/syncAddonTool/pyproject.py @@ -421,6 +421,17 @@ def mergePyprojectToml( wasOriginallyNvaccess = True break + metadataRepositoryUrl: str = str( + metadataDict.get("addon_sourceURL") or metadataDict.get("addon_url") or "" + ).strip() + if metadataRepositoryUrl and "project" in projTomlData: + originalUrls: Any = projTomlData["project"].get("urls") + if originalUrls is None: + projTomlData["project"]["urls"] = tomlkit.table() + originalUrls = projTomlData["project"]["urls"] + if isinstance(originalUrls, MutableMapping) and not originalUrls.get("Repository"): + originalUrls["Repository"] = metadataRepositoryUrl + mergedDictData: dict[str, Any] = deepMergeDicts( cast(dict[str, Any], projTomlData), cast(dict[str, Any], tplTomlData) ) From fc1e8f4b0402b16684c3c123d83ed02e23751233 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 17 Oct 2026 00:16:56 +0200 Subject: [PATCH 51/52] test(e2e): use temporary paths for PyInstaller packaging test - Replace destructive removal of local build/ and dist/ directories with a temporary directory context for PyInstaller builds. - Configure custom --workpath and --distpath arguments to isolate packaging outputs and protect unrelated developer artifacts. --- tests/e2e/template/test_syncAddonTool.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/tests/e2e/template/test_syncAddonTool.py b/tests/e2e/template/test_syncAddonTool.py index 579539d..35df079 100644 --- a/tests/e2e/template/test_syncAddonTool.py +++ b/tests/e2e/template/test_syncAddonTool.py @@ -375,11 +375,10 @@ def test_pyinstaller_executable_build(self) -> None: if not specPath.exists(): self.skipTest("syncAddonTool/syncAddonTool.spec not found in repository.") - # Clean previous build and dist artifacts to force a fresh compilation - for dirName in ["build", "dist"]: - dirPath: Path = Path(dirName) - if dirPath.exists(): - shutil.rmtree(dirPath) + # Keep packaging outputs separate from existing developer artifacts. + tempDirObj: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory() + self.addCleanup(tempDirObj.cleanup) + tempPath: Path = Path(tempDirObj.name) result: subprocess.CompletedProcess[str] = subprocess.run( [ @@ -390,6 +389,10 @@ def test_pyinstaller_executable_build(self) -> None: "pyinstaller", "syncAddonTool/syncAddonTool.spec", "--noconfirm", + "--workpath", + str(tempPath / "build"), + "--distpath", + str(tempPath / "dist"), ], capture_output=True, text=True, @@ -397,8 +400,8 @@ def test_pyinstaller_executable_build(self) -> None: ) self.assertEqual(result.returncode, 0, f"PyInstaller build failed: {result.stderr}") - expectedExe: Path = Path("dist/syncAddonTool.exe") - self.assertTrue(expectedExe.exists(), "Expected dist/syncAddonTool.exe to be generated") + expectedExe: Path = tempPath / "dist" / "syncAddonTool.exe" + self.assertTrue(expectedExe.exists(), f"Expected {expectedExe} to be generated") if __name__ == "__main__": From 68c092e7cb4cc51b0fa0a5f8675ad46aee512a97 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sat, 17 Oct 2026 21:26:04 +0200 Subject: [PATCH 52/52] docs(updating-addons): replace git clean -f with safer staging command - Update updatingExistingAddons.md documentation to remove risky git clean -f command. - Recommend checking changes with git status --short before staging and committing infrastructure updates. --- 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 e0c6a07..2e9a9aa 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -162,7 +162,7 @@ uv run scons If everything builds successfully, remove the `_bak_` directory, stage and commit the updated infrastructure: ```sh -git clean -f +git status --short git add . git commit -m "Chore: sync infrastructure with AddonTemplate" ```