From 574bd731f4cbd21565ac75103f218895815395e3 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 4 Aug 2026 12:12:33 +0200 Subject: [PATCH 01/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] 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/21] - 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/21] 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/21] 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/21] 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/21] 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: