diff --git a/.addonmergeignore b/.addonmergeignore new file mode 100644 index 0000000..e3b05d0 --- /dev/null +++ b/.addonmergeignore @@ -0,0 +1,2 @@ +# Files and directories ignored during template synchronization +.addonmergeignore 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/ diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md index 4895903..6db37ab 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -42,7 +42,7 @@ As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workfl You can merge the latest template changes into your repository instead of manually copying updated files. -This document explains the recommended update procedure. +*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. @@ -81,7 +81,287 @@ Then fetch the latest changes: git fetch template ``` -## Merging the latest 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: `syncAddonTool`. + +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 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`. + +### 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`). + +> [!IMPORTANT] +> **Project Structure & Execution Methods:** +> The update engine is structured as a Python package that relies on the `syncAddonTool/` directory layout. +> You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run python syncAddonTool`. +> Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run python -m syncAddonTool -ad /path/to/my-nvda-addon`. +> Finally, you can use the standalone executable (`syncAddonTool.exe`), which requires no Python dependencies. + +### Running the automated tool + +The tool is highly flexible and supports two execution modes: + +1. **Standard Mode (No arguments):** + Run the tool directly from the root of your repository or from any of its subdirectories. + It will automatically locate the project root by searching for `buildVars.py`. + + ```sh + uv run python syncAddonTool + ``` + +2. **Target Directory Mode (With argument):** + Run the tool from any working directory by supplying the optional `addonDir` path (relative or absolute) pointing to the add-on repository you wish to update. + + ```sh + uv run python syncAddonTool -ad ../MyAddon + ``` + +> [!NOTE] +> Before applying any modifications, the tool creates an untracked backup directory located next to the add-on folder named `_bak_`. +> This directory contains a copy of the entire project before the update, allowing you to restore the previous state manually if necessary. + +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 `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 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) | +| `-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 + +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/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 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 + ``` + +#### Customizing Exclusions with `.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. + +##### 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, 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: + +```gitignore +# Preserve local release workflows +.github/workflows/release.yml + +# Protect custom localized documentation +addon/doc/fr/custom-extra-help.html +``` + +##### Crucial Requirements & Design Constraints + +1. **Automatic Self-Exclusion:** + The update tool automatically protects `.addonmergeignore` itself from being overwritten during synchronization. Even if `.addonmergeignore` is present in the template repository, the target add-on's local `.addonmergeignore` file is preserved without needing to explicitly list itself. + +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 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 `syncAddonTool` tool from a completely different directory or an external workspace. + +#### Usage Examples + +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 (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run python syncAddonTool -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 `AddonTemplate` or when working without an active internet connection. + +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool -td /path/to/local/AddonTemplate + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run python syncAddonTool -ad /path/to/my-nvda-addon -td /path/to/local/AddonTemplate + ``` + +- **Syntax D (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 (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool --dry-run + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run python syncAddonTool --dry-run -ad /path/to/my-nvda-addon + ``` + +- **Syntax C (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 (Directory execution inside the add-on repository)**: + + ```sh + uv run python syncAddonTool --skip-backup + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run python syncAddonTool -ad /path/to/my-nvda-addon --skip-backup + ``` + +- **Syntax C (Standalone executable)**: + + ```cmd + syncAddonTool.exe -ad C:\path\to\my-nvda-addon --skip-backup + ``` + +##### 5. Run without Prior Installation (`--with` option) + +If you wish to execute the synchronization tool without installing its required third-party dependencies (like `tomlkit`) into your active environment beforehand, you can request `uv` to expose them temporarily during command execution: + +- **Using directory execution with `uv`**: + + ```sh + uv run --with tomlkit python syncAddonTool + ``` + +- **Using module execution with `uv`**: + + ```sh + uv run --with tomlkit python -m syncAddonTool + ``` + +- **Using Standalone Executable**: + + *(Note: No `--with` option or dependency installation is needed when running `syncAddonTool.exe`, as all required dependencies are already bundled inside the executable.)* + + ```cmd + syncAddonTool.exe + ``` + +--- + +## Alternative Method: Manual Update Using Git Merge + +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: diff --git a/docs/unitTesting.md b/docs/unitTesting.md index fece67f..70ee851 100644 --- a/docs/unitTesting.md +++ b/docs/unitTesting.md @@ -2,16 +2,34 @@ 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. + +### Run Specific Test Suites + +You can run individual test modules during development by specifying their path: + +* **Add-on Synchronization Tool Tests:** + ``` bash + uv run python -m unittest -v tests/unit/template/test_syncAddonTool + ``` + +--- -``` bash -uv run python -m unittest -v tests/unit/template/test_sanity.py -``` diff --git a/pyproject.toml b/pyproject.toml index b0d1008..e473a0b 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", + "syncAddonTool", "tests", ] @@ -119,6 +121,7 @@ exclude = [ ".venv", "site_scons", ".github/scripts", + "syncAddonTool", "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/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..829af95 --- /dev/null +++ b/syncAddonTool/__main__.py @@ -0,0 +1,20 @@ +# 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 + +# 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() 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..817a558 --- /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.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)) + 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..ee13b44 --- /dev/null +++ b/syncAddonTool/pyproject.py @@ -0,0 +1,484 @@ +# 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], + 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 + 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. + :param contextName: Optional key/section name for logging context. + :return: A merged list with updated versions and preserved custom or newer user items. + """ + isActualDependencyList: bool = contextName in ["dependencies", "dependency-groups"] + # 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 + 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: + if shouldLogDecisions: + 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: + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: KEEP USER VERSION (%s > %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + userItemText, + ) + mergedList.append(userItemText) + continue + else: + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: USE TEMPLATE VERSION (%s <= %s) -> %r", + tplBaseName, + userVersionTuple, + tplVersionTuple, + tplItem, + ) + except ValueError: + pass + + mergedList.append(tplItem) + else: + if shouldLogDecisions: + 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: + if shouldLogDecisions: + logger.debug( + "DECISION [%s]: REPLACED BY TEMPLATE EQUIVALENT %r", + baseName, + REPLACED_PACKAGES[baseName], + ) + continue + if shouldLogDecisions: + logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY %r", depItem) + mergedList.append(depItem) + + return mergedList + + +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, parentPath=fullPath) + elif isinstance(projVal, MutableSequence) and isinstance(dictValue, MutableSequence): + projDict[dictKey] = mergeDependencyLists(list(projVal), list(dictValue), contextName=fullPath) + 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 + logger.debug("DECISION [custom]: PRESERVE USER DEPENDENCY IN PROJECT.DEPENDENCIES %r", depItem) + 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/syncAddonTool.spec b/syncAddonTool/syncAddonTool.spec new file mode 100644 index 0000000..8d8e22b --- /dev/null +++ b/syncAddonTool/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( + ["__main__.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/utils.py b/syncAddonTool/utils.py new file mode 100644 index 0000000..f500815 --- /dev/null +++ b/syncAddonTool/utils.py @@ -0,0 +1,131 @@ +# 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) + outputText = re.sub(r"\n{3,}", "\n\n", outputText) + 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/tests/unit/__init__.py b/tests/unit/__init__.py index 5d53c4a..7e3f33a 100644 --- a/tests/unit/__init__.py +++ b/tests/unit/__init__.py @@ -2,4 +2,4 @@ # 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 repository.""" 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..73e4301 --- /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", +] diff --git a/tests/unit/template/fixtures/userPyproject.toml b/tests/unit/template/fixtures/userPyproject.toml new file mode 100644 index 0000000..7b75583 --- /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", +] 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/template/test_syncAddonTool.py b/tests/unit/template/test_syncAddonTool.py new file mode 100644 index 0000000..890f0bf --- /dev/null +++ b/tests/unit/template/test_syncAddonTool.py @@ -0,0 +1,357 @@ +# 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 syncAddonTool package.""" + +import tempfile +import unittest +from pathlib import Path + +# 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, + mergeDependencyLists, + mergePyprojectToml, +) + +FIXTURES_DIR: Path = Path(__file__).parent / "fixtures" + + +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. + """ + orderIndex: dict[str, int] = { + 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(TestSyncAddonTool) + + +class TestSyncAddonTool(unittest.TestCase): + """Test cases for checking synchronization logic and file merges.""" + + def testMergeLegacyBuildvarsWithOfficialTemplate(self) -> None: + """Ensure legacy buildVars.py is correctly merged into the latest official template structure.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # 1. Legacy buildVars fixture + legacyFixture: Path = FIXTURES_DIR / "legacyBuildVars.py" + projBvPath.write_text(legacyFixture.read_text(encoding="utf-8"), encoding="utf-8") + + # 2. Official template buildVars fixture + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status: str = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content: str = projBvPath.read_text(encoding="utf-8") + # Verify metadata mapping from fixture + self.assertIn("addon_name='myAddon'", content) + self.assertIn("addon_version='1.0.0'", content) + # Verify new official template imports + self.assertIn("from site_scons.site_tools.NVDATool.typings import", content) + + def testMergeModernBuildvarsMissingSpeechDictionaries(self) -> None: + """Ensure modern buildVars.py gets missing speechDictionaries imported/injected from official template.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # 1. Modern buildVars fixture (without SpeechDictionaries imported in original) + modernFixture: Path = FIXTURES_DIR / "modernBuildVars.py" + projBvPath.write_text(modernFixture.read_text(encoding="utf-8"), encoding="utf-8") + + # 2. Official template buildVars fixture + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status: str = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name='myAddon'", content) + self.assertIn("SpeechDictionaries", content) + + def 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. + """ + 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 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 + 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 + runSynchronization( + tempDir=templateDir, + addonDir=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: + 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( + 'import os\n' + 'pythonSources = [os.path.join("addon", "*.py")]\n', + encoding="utf-8", + ) + + tplBvPath.write_text( + 'pythonSources: list[str] = []\n', + encoding="utf-8", + ) + + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) + + content: str = projBvPath.read_text(encoding="utf-8") + self.assertTrue(content.startswith("import os\n")) + + def testFixTomlIndentation(self) -> None: + """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' + "]\n" + "otherSection = {\n" + ' key = "value"\n' + "}\n" + ) + expectedOutput: str = ( + 'name = "myAddon"\n' + "maintainers = [\n" + '\t{name = "John Doe", email = "john@example.com"},\n' + "]\n" + "otherSection = {\n" + '\tkey = "value"\n' + "}\n" + ) + + result: str = fixTomlIndentation(inputToml) + self.assertEqual(result, expectedOutput) + + def testFormatAuthorList(self) -> None: + """Ensure raw author string parsing produces a formatted tomlkit array.""" + rawAuthors: str = "John Doe , Jane Smith" + authorsArray: list = 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") + # 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.""" + 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: list[str] = 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 = Path(tempDir) / "pyproject.toml" + tplToml: Path = 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: str = mergePyprojectToml( + projToml, tplToml, metadataDict={}, dryRun=False + ) + self.assertEqual(status, "merged intelligently (tomlkit)") + + 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() 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"