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/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1834a17..b8f7535 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ on: jobs: run-tests: name: Run Test Suite (Unit & E2E) - runs-on: ubuntu-latest + runs-on: windows-latest steps: - name: Checkout repository 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 7d7c0f8..2e9a9aa 100644 --- a/docs/managementFromGit/updatingExistingAddons.md +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -42,10 +42,10 @@ 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. +> **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 @@ -81,7 +81,278 @@ 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`. + +- **Dependency Management (`tomlkit`)**: + The synchronization engine relies on `tomlkit` to safely parse and update `pyproject.toml`. + You have two options to make this dependency available: + + - **On-the-fly via global cache (Recommended):** + Pass `--with tomlkit` along with `--no-project` to `uv run`. + `uv` automatically downloads `tomlkit` into its global cache (`%LocalAppData%\uv\cache` on Windows) and reuses it for all future executions without modifying your local environment. + - **Permanent Global Installation:** + If you prefer to make `tomlkit` permanently available inside `uv`'s managed Python environment, run the following command: + ```sh + uv pip install --python 3.13 tomlkit + ``` + This command installs `tomlkit` directly into the `site-packages` directory of `uv`'s central Python 3.13 installation. + Once installed globally, you can execute the tool using `uv run --no-project python syncAddonTool` without needing to append `--with tomlkit` to every command. + +> **Important:** +> Project Structure & Execution Methods: +> The update engine is structured as a Python package that relies on the `syncAddonTool/` directory layout. +> You can copy the `syncAddonTool/` folder directly into any add-on repository and run `uv run --no-project --with tomlkit python syncAddonTool`. +> Alternatively, if you prefer to run the tool from an external directory outside of the target repository, you can specify its location using the `-ad` parameter: `uv run --no-project --with tomlkit python -m syncAddonTool -ad /path/to/my-nvda-addon`. +> Finally, you can use the standalone executable (`syncAddonTool.exe`), which requires no Python dependencies. + +### Running the automated tool + +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 --no-project --with tomlkit python syncAddonTool + ``` + +2. **Target Directory Mode (With argument):** + Run the tool from any working directory by supplying the optional `addonDir` path (relative or absolute) pointing to the add-on repository you wish to update. + ```sh + uv run --no-project --with tomlkit 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 status --short +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, `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 exact relative paths, sub-folder paths, or pattern rules of the files or folders you want the tool to skip during synchronization. +- Patterns are matched using standard `fnmatch` rules (with support for `!` prefix to unprotect specific sub-paths). +- You can write one pattern per line. +Empty lines and lines starting with `#` are automatically treated as comments and ignored. + +For instance, if you wish to prevent the synchronization process from overwriting your custom execution scripts or specific workflows, simply add them to the file: + +```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., `syncAddonTool` or `syncaddontool` will both work perfectly). + +4. **File Location Requirement:** + The update engine always loads custom exclusions from the target add-on's root folder being updated. + 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 --no-project --with tomlkit python syncAddonTool + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run --no-project --with tomlkit 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 --no-project --with tomlkit python syncAddonTool -td /path/to/local/AddonTemplate + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run --no-project --with tomlkit python syncAddonTool -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 `.addonmergeignore` directives, and builds reports without writing anything to disk. + +- **Syntax A (Directory execution inside the add-on repository)**: + + ```sh + uv run --no-project --with tomlkit python syncAddonTool --dry-run + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run --no-project --with tomlkit 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 --no-project --with tomlkit python syncAddonTool --skip-backup + ``` + +- **Syntax B (Targeting an external add-on directory)**: + + ```sh + uv run --no-project --with tomlkit 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 + ``` + +--- + +## 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/testing.md b/docs/testing.md index f38edf1..136ffde 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -10,22 +10,29 @@ To run the entire test suite (both unit and E2E tests) locally using `uv`: uv run python -m unittest discover -s tests -v ``` +Here is what each part of the command does: + +* `uv`: Executes the command within the virtual environment managed by `uv`. +* `python -m unittest discover`: Automatically finds and runs all test files (matching `test*.py`) within the specified test directory. +* `-s tests`: Sets the start directory for test discovery to the `tests/` folder. +* `-v`: Enables verbose output, displaying the status and description of each test method individually. + ## Unit Testing -Unit tests are located under the `tests/unit/` directory. +Unit tests are located under the `tests/unit/` directory to validate internal synchronization logic, AST modifications, and helpers. To execute tests for a specific unit test file: ```bash -uv run python -m unittest -v tests/unit/template/test_sanity.py +uv run python -m unittest -v tests/unit/template/test_syncAddonTool.py ``` ## End-to-End (E2E) Testing -End-to-end tests are located under the `tests/e2e/` directory to validate system-level workflows and CLI integration. +End-to-end tests are located under the `tests/e2e/` directory to validate system-level workflows, CLI interfaces, and real-world packaging. To execute tests for a specific E2E test file: ```bash -uv run python -m unittest -v tests/e2e/template/test_sanity.py +uv run python -m unittest -v tests/e2e/template/test_syncAddonTool.py ``` diff --git a/pyproject.toml b/pyproject.toml index b0d1008..ae2bdce 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 = [ @@ -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..a81bf23 --- /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..20b690f --- /dev/null +++ b/syncAddonTool/astUtils.py @@ -0,0 +1,96 @@ +# 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] + if isinstance(valNode, ast.Constant): + valValue: Any = valNode.value + else: + valValue = ast.unparse(valNode) + 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] + if isinstance(valNode, ast.Constant): + valValue: Any = valNode.value + else: + valValue = ast.unparse(valNode) + 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 usesGlobModule(astNode: ast.AST) -> bool: + """Check recursively if an AST node references 'glob'. + + :param astNode: The AST node to inspect. + :return: True if the node references 'glob', False otherwise. + """ + childNode: ast.AST + for childNode in ast.walk(astNode): + if isinstance(childNode, ast.Name) and childNode.id == "glob": + return True + if isinstance(childNode, ast.Call) and getattr(childNode.func, "id", None) == "glob": + return True + return False + + +def replaceAstRange(templateLineList: list[str], replacementMap: dict[tuple[int, int], str]) -> None: + """Apply AST line replacements on a line-by-line list in reverse order. + + :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..86ee8b7 --- /dev/null +++ b/syncAddonTool/buildVarsSync.py @@ -0,0 +1,222 @@ +# 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, usesGlobModule + +logger: logging.Logger = logging.getLogger("syncAddon") + + +def formatStringLiteral(val: str, useDoubleQuotes: bool = False, isTranslatable: bool = False) -> str: + """Format a string literal preserving either double or single quotes. + + :param val: The string value to format. + :param useDoubleQuotes: If True, format using double quotes; otherwise default to single quotes. + :param isTranslatable: If True, wrap the formatted string in the gettext `_()` call. + :return: The formatted string literal representation, optionally wrapped in `_()`. + """ + formattedText: str + if useDoubleQuotes: + # Escape control characters, backslashes, and double quotes. + escapedValText: str = val.encode("unicode_escape").decode("ascii").replace('"', '\\"') + formattedText = f'"{escapedValText}"' + else: + # Default style using repr (single quotes) + formattedText = repr(val) + + return f"_({formattedText})" if isTranslatable else formattedText + + +def extractBuildvarsMetadata(filePath: str | Path) -> tuple[dict[str, Any], dict[str, tuple[ast.AST, str]]]: + """Extract metadata and raw assignment expressions along with AST nodes from buildVars.py safely. + + :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) + raise + + metadataDict: dict[str, Any] = {} + globalVarsDict: dict[str, tuple[ast.AST, str]] = {} + topLevelVarsSet: set[str] = { + "pythonSources", + "i18nSources", + "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: + # Preserve raw source segment to maintain original quote formatting + rawSegmentText: str | None = ast.get_source_segment(tplContentText if 'tplContentText' in locals() else f.read(), astNodeItem.value) if False else None + # Note: Read source directly or fallback to ast.unparse + sourceSegmentText: str = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) + globalVarsDict[varNameText] = (astNodeItem.value, sourceSegmentText) + elif isinstance(astNodeItem, ast.AnnAssign): + if isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id == "addon_info": + if isinstance(astNodeItem.value, ast.Dict): + metadataDict.update(parseAstDict(astNodeItem.value)) + elif isinstance(astNodeItem.value, ast.Call) and getattr(astNodeItem.value.func, "id", None) == "AddonInfo": + metadataDict.update(parseAstKeywords(astNodeItem.value.keywords)) + elif isinstance(astNodeItem.target, ast.Name) and astNodeItem.target.id in topLevelVarsSet: + if astNodeItem.value is not None: + sourceSegmentText = ast.get_source_segment(fileRootPath.read_text(encoding="utf-8"), astNodeItem.value) or ast.unparse(astNodeItem.value) + globalVarsDict[astNodeItem.target.id] = (astNodeItem.value, sourceSegmentText) + + 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. + """ + requiresOsImport: bool = False + requiresGlobImport: bool = False + + 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] = {} + + 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(kwItem.value, ast.Constant): + # Handle direct literal values (strings, numbers, booleans) + isTranslatable: bool = keyName in ["addon_summary", "addon_description", "addon_changelog"] + + rawSourceText: str = ast.get_source_segment(tplContentText, kwItem.value) or "" + cleanedSourceText: str = rawSourceText.strip() + useDoubleQuotesBool: bool = cleanedSourceText.startswith('"') or cleanedSourceText.startswith('_("') + + formattedValueText = formatStringLiteral( + str(valueVal), + useDoubleQuotes=useDoubleQuotesBool, + isTranslatable=isTranslatable, + ) + else: + # Preserve non-literal expressions (e.g. variables or function calls) without forcing quotes + formattedValueText = ast.get_source_segment(tplContentText, kwItem.value) or ast.unparse(kwItem.value) + + if kwItem.end_lineno is not None: + lineContentText: str = templateLineList[kwItem.lineno - 1] + 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 usesGlobModule(valAstNode): + requiresGlobImport = 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 usesGlobModule(valAstNode): + requiresGlobImport = 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 requiresGlobImport: + hasGlobImport: bool = any("from glob import glob" in line or "import glob" in line for line in templateLineList[:15]) + if not hasGlobImport: + # Insert glob import right after os import or at the top + insertIndex: int = 1 if any("import os" in line for line in templateLineList[:2]) else 0 + templateLineList.insert(insertIndex, "from glob import glob\n") + + if not dryRun: + with projectPathObj.open("w", encoding="utf-8") as f: + f.writelines(templateLineList) + return "merged & structured (AST verified)" diff --git a/syncAddonTool/cli.py b/syncAddonTool/cli.py new file mode 100644 index 0000000..6d84106 --- /dev/null +++ b/syncAddonTool/cli.py @@ -0,0 +1,178 @@ +# 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) + syncSuccess: bool = 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) + + syncSuccess = runSynchronization(tempDir, addonDir, parsedArgs.dryRun) + + if not syncSuccess: + logger.error("Update process completed with errors. Check logs above.") + if sys.stdin.isatty(): + input("\nPress Enter to exit...") + sys.exit(1) + + if not parsedArgs.dryRun: + logger.info("Project successfully updated. Workspace cleared.") + else: + logger.info("Simulation finished. Workspace cleared.") diff --git a/syncAddonTool/engine.py b/syncAddonTool/engine.py new file mode 100644 index 0000000..fe52543 --- /dev/null +++ b/syncAddonTool/engine.py @@ -0,0 +1,228 @@ +# 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 fnmatch +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) -> bool: + """Synchronize template machinery files from the temporary workspace into the target directory. + + :param tempDir: Path to the local temporary directory containing the template files. + :param addonDir: Path to the target add-on root directory. + :param dryRun: If True, simulate the sync without writing changes to disk. + :return: True if synchronization completed without errors, False otherwise. + """ + logger.info("Phase 4: Synchronizing template machinery files...") + setupAddonMergeIgnore(tempDir, addonDir, dryRun) + + protectedElementsSet: set[str] = { + ".addonmergeignore", + "readme.md", + "changelog.md", + "addontemplate.egg-info", + ".github/workflows/tests.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 isPathProtected(relPathText: str) -> bool: + """Check whether a given relative path matches any defined protection rules or wildcard patterns. + + :param relPathText: Relative path string to verify. + :return: True if the path matches a protected element or pattern, False otherwise. + """ + pathNormalizedText: str = relPathText.replace("\\", "/").lower() + isDirectory: bool = os.path.isdir(os.path.join(tempDir, relPathText)) + patternItem: str + for patternItem in protectedElementsSet: + if patternItem.endswith("/"): + if not isDirectory: + continue + patternItem = patternItem.rstrip("/") + if pathNormalizedText == patternItem or fnmatch.fnmatch(pathNormalizedText, patternItem): + return True + if fnmatch.fnmatch(os.path.basename(pathNormalizedText), patternItem): + return True + return False + + 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) + if isPathProtected(relPathText): + 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) + if isPathProtected(relPathText): + 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): + if isPathProtected(rootItemName): + 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)) + + # Honor .addonmergeignore rules for structural configuration files as well + buildvarsStatusText: str = ( + "skipped (protected scope)" + if isPathProtected("buildVars.py") + else mergeBuildvarsFile( + oldBuildvarsPath, + templateBuildvarsPath, + buildvarsMetadataDict, + buildvarsGlobalsDict, + dryRun, + ) + ) + pyprojectStatusText: str = ( + "skipped (protected scope)" + if isPathProtected("pyproject.toml") + else 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, + ) + + hasFailuresBool: bool = ( + any("failed" in reportEntryItem for reportEntryItem in syncReportList) + or "failed" in buildvarsStatusText.lower() + or "failed" in pyprojectStatusText.lower() + ) + return not hasFailuresBool diff --git a/syncAddonTool/pyproject.py b/syncAddonTool/pyproject.py new file mode 100644 index 0000000..d412067 --- /dev/null +++ b/syncAddonTool/pyproject.py @@ -0,0 +1,575 @@ +# 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_sourceURL") or metadataDict.get("addon_url") or "" + ).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"]) + + # Ensure that [tool.setuptools] is always present at creation --- + if "tool" not in tomlDoc: + tomlDoc["tool"] = tomlkit.table() + + if "setuptools" not in tomlDoc["tool"]: + setuptoolsTable = tomlkit.table() + setuptoolsTable["py-modules"] = tomlkit.array() + tomlDoc["tool"]["setuptools"] = setuptoolsTable + + return tomlDoc + + +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 = ( + not contextName + or contextName == "dependencies" + or contextName.endswith(".dependencies") + or "dependency-groups" in contextName + ) + + # Restrict requirement/package matching strictly to dependency arrays + if not isActualDependencyList: + # For non-dependency arrays (e.g. classifiers, include, exclude, paths), + # apply an explicit preservation and complete-value union policy without package parsing. + mergedList: list[Any] = list(projList) + for item in tplList: + if item not in mergedList: + mergedList.append(item) + return mergedList + + # Only emit decision logs if processing dependency-groups, preventing duplicate + # or premature decision logs on flat project.dependencies before migration/purge. + shouldLogDecisions: bool = contextName != "dependencies" and isActualDependencyList + + 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 + + metadataRepositoryUrl: str = str( + metadataDict.get("addon_sourceURL") or metadataDict.get("addon_url") or "" + ).strip() + if metadataRepositoryUrl and "project" in projTomlData: + originalUrls: Any = projTomlData["project"].get("urls") + if originalUrls is None: + projTomlData["project"]["urls"] = tomlkit.table() + originalUrls = projTomlData["project"]["urls"] + if isinstance(originalUrls, MutableMapping) and not originalUrls.get("Repository"): + originalUrls["Repository"] = metadataRepositoryUrl + + mergedDictData: dict[str, Any] = deepMergeDicts( + cast(dict[str, Any], projTomlData), cast(dict[str, Any], tplTomlData) + ) + # Remove static metadata fields from the merged dynamic list to comply with PEP 621 + projectMetadata: Any = mergedDictData.get("project") + if isinstance(projectMetadata, MutableMapping) and "dynamic" in projectMetadata: + projectMetadata["dynamic"] = [ + fieldName for fieldName in projectMetadata["dynamic"] + if fieldName not in projectMetadata + ] + if not projectMetadata["dynamic"]: + del projectMetadata["dynamic"] + processDependencyGroupsMigration(mergedDictData, userFlatDepsList) + + if "project" in mergedDictData: + projectSectionDict: dict[str, Any] = mergedDictData["project"] + + if not wasOriginallyNvaccess: + cleanupPlaceholderAuthors(projectSectionDict) + + addonUrlText: str = str( + metadataDict.get("addon_sourceURL") or metadataDict.get("addon_url") or "" + ).strip() + if addonUrlText: + if "urls" not in projectSectionDict or not isinstance(projectSectionDict["urls"], MutableMapping): + projectSectionDict["urls"] = {} + if not projectSectionDict["urls"].get("Repository"): + projectSectionDict["urls"]["Repository"] = addonUrlText + + finalDoc: tomlkit.TOMLDocument = tomlkit.document() + sectionKey: str + sectionVal: Any + # Copy primary sections in preferred order + if "build-system" in mergedDictData: + finalDoc["build-system"] = mergedDictData["build-system"] + + if "project" in mergedDictData: + finalDoc["project"] = mergedDictData["project"] + + # Insert dependency-groups immediately after the project section + if "dependency-groups" in mergedDictData: + if "project" in finalDoc: + insertKeyAfter( + finalDoc, + targetKey="project", + newKey="dependency-groups", + value=mergedDictData["dependency-groups"], + ) + else: + finalDoc["dependency-groups"] = mergedDictData["dependency-groups"] + + # Append any remaining sections (e.g. tool tables) + for sectionKey, sectionVal in mergedDictData.items(): + if sectionKey not in finalDoc: + 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") + + # Automatic control and injection of [tool.setuptools] --- + if "tool" not in finalDoc: + finalDoc["tool"] = tomlkit.table() + + toolTable: Any = finalDoc["tool"] + if "setuptools" not in toolTable: + toolTable["setuptools"] = tomlkit.table() + + setuptoolsTable: Any = toolTable["setuptools"] + if "py-modules" not in setuptoolsTable: + setuptoolsTable["py-modules"] = [] + logger.debug("Added missing [tool.setuptools] py-modules section to final document") + + makeMultilineArray(setuptoolsTable, "py-modules") + + if not dryRun: + tomlFormattedText: str = fixTomlIndentation(tomlkit.dumps(finalDoc)).lstrip() + 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..a324c95 --- /dev/null +++ b/syncAddonTool/syncAddonTool.spec @@ -0,0 +1,115 @@ +# -*- 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", + ], + 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/e2e/template/fixtures/addonTemplate/buildVars.py b/tests/e2e/template/fixtures/addonTemplate/buildVars.py new file mode 100644 index 0000000..fde02e7 --- /dev/null +++ b/tests/e2e/template/fixtures/addonTemplate/buildVars.py @@ -0,0 +1,111 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name="addonTemplate", + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_("Add-on user visible name"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_("""Description for the add-on. +It can span multiple lines."""), + # version + addon_version="x.y", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("""Changelog for the add-on version. +It can span multiple lines."""), + # Author(s) + addon_author="name ", + # URL for the add-on documentation support + addon_url=None, + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName="readme.html", + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion=None, + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion=None, + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = [] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +symbolDictionaries: SymbolDictionaries = {} + +# Custom speech dictionaries (distinct from symbol dictionaries above) +# Speech dictionary files reside in the speechDicts folder and are named `name.dic`. +# If your add-on includes custom speech (pronunciation) dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +speechDictionaries: SpeechDictionaries = {} diff --git a/tests/e2e/template/fixtures/addonTemplate/pyproject.toml b/tests/e2e/template/fixtures/addonTemplate/pyproject.toml new file mode 100644 index 0000000..ae2bdce --- /dev/null +++ b/tests/e2e/template/fixtures/addonTemplate/pyproject.toml @@ -0,0 +1,236 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +# This package is not installed as a library; every dependency is tooling used to +# build, translate, or lint the add-on. They are declared as PEP 735 dependency groups. +dependencies = [] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + +# PEP 735 dependency groups. `uv sync` installs the `dev` group by default, which +# pulls in every tool needed to build, translate, and lint the add-on. +[dependency-groups] +# Build add-on & repository synchronization machinery +build = [ + "scons==4.10.1", + "Markdown==3.10", + "tomlkit==0.15.0", +] +# Translations management +l10n = [ + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.1.0", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", +] +# Lint +lint = [ + "uv==0.11.15", + "ruff==0.14.5", + "prek==0.4.8", + "pyright[nodejs]==1.1.407", +] +dev = [ + { include-group = "build" }, + { include-group = "l10n" }, + { include-group = "lint" }, +] + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", + "syncAddonTool", + "tests", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + ".github/scripts", + "syncAddonTool", + "tests", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. + +[tool.setuptools] +py-modules = [] diff --git a/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py b/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py new file mode 100644 index 0000000..55faf01 --- /dev/null +++ b/tests/e2e/template/fixtures/clipContentsDesigner/buildVars.py @@ -0,0 +1,110 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name='clipContentsDesigner', + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_('Clip Contents Designer'), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_('Allows the joining of separate fragments of text on the clipboard and clearing of clipboard content. Current text on the clipboard can be presented in browse mode. Option to request confirmation before performing actions such as copy, cut, add text, or clearing the clipboard to avoid accidental changes.'), + # version + addon_version="52.2.0", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("""* Restored ability to check the clipboard content. +* Updates to translations."""), + # Author(s) + addon_author='Noelia , Abdel ', + # URL for the add-on documentation support + addon_url='https://github.com/nvdaes/clipContentsDesigner', + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName='readme.html', + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion='2026.1', + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion='2026.3', + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = ['addon/*.py', 'addon/globalPlugins/clipContentsDesigner/*.py'] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = 'en' + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +symbolDictionaries: SymbolDictionaries = {} + +# Custom speech dictionaries (distinct from symbol dictionaries above) +# Speech dictionary files reside in the speechDicts folder and are named `name.dic`. +# If your add-on includes custom speech (pronunciation) dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not). +speechDictionaries: SpeechDictionaries = {} diff --git a/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml b/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml new file mode 100644 index 0000000..b977f71 --- /dev/null +++ b/tests/e2e/template/fixtures/clipContentsDesigner/pyproject.toml @@ -0,0 +1,228 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "clipContentsDesigner" +dynamic = ["version"] +description = "Clip Contents Designer" +maintainers = [ + {name = "Noelia", email = "nrm1977@gmail.com"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +# This package is not installed as a library; every dependency is tooling used to +# build, translate, or lint the add-on. They are declared as PEP 735 dependency groups. +dependencies = [] +[project.urls] +Repository = "https://github.com/nvdaes/clipContentsDesigner" + +# PEP 735 dependency groups. `uv sync` installs the `dev` group by default, which +# pulls in every tool needed to build, translate, and lint the add-on. +[dependency-groups] +# Build add-on +build = [ + "scons==4.10.1", + "Markdown==3.10", +] +# Translations management +l10n = [ + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.1.0", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", +] +# Lint +lint = [ + "uv==0.11.15", + "ruff==0.14.5", + "prek==0.4.8", + "pyright[nodejs]==1.1.407", +] +dev = [ + { include-group = "build" }, + { include-group = "l10n" }, + { include-group = "lint" }, +] + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + ".github/scripts", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. diff --git a/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py b/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py new file mode 100644 index 0000000..2ec9234 --- /dev/null +++ b/tests/e2e/template/fixtures/dayOfTheWeek-legacy/buildVars.py @@ -0,0 +1,79 @@ +# -*- coding: UTF-8 -*- + +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply roll our own "fake" `_` function +# which returns whatever is given to it as an argument. +def _(arg): + return arg + + +# Add-on information variables +addon_info = { + # add-on Name/identifier, internal for NVDA + "addon_name": "dayOfTheWeek", + # Add-on summary, usually the user visible name of the addon. + # Translators: Summary for this add-on + # to be shown on installation and add-on information found in Add-ons Manager. + "addon_summary": _("Day of the week"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-ons manager + "addon_description": _("""With this add-on, you'll be able to find the day of the week corresponding to a chosen date."""), + # version + "addon_version": "20251022.0.1", + # Author(s) + "addon_author": "Abdel , Noelia ", + # URL for the add-on documentation support + "addon_url": "https://github.com/abdel792/dayOfTheWeek", + # URL for the add-on repository where the source code can be found + "addon_sourceURL": "https://github.com/abdel792/dayOfTheWeek", + # Documentation file name + "addon_docFileName": "readme.html", + # Minimum NVDA version supported (e.g. "2018.3.0", minor version is optional) + "addon_minimumNVDAVersion": "2019.3.0", + # Last NVDA version supported/tested (e.g. "2018.4.0", ideally more recent than minimum version) + "addon_lastTestedNVDAVersion": "2026.1.0", + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + "addon_updateChannel": None, + # Add-on license such as GPL 2 + "addon_license": "GPL v2", + # URL for the license document the ad-on is licensed under + "addon_licenseURL": "https://www.gnu.org/licenses/gpl-2.0.html", +} + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +import os +pythonSources = [os.path.join("addon", "globalPlugins", "*.py")] + +# Files that contain strings for translation. Usually your python sources +i18nSources = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +excludedFiles = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +baseLanguage = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions = [] diff --git a/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py b/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py new file mode 100644 index 0000000..d126bdb --- /dev/null +++ b/tests/e2e/template/fixtures/placeMarkers-modern/buildVars.py @@ -0,0 +1,101 @@ +# Build customizations +# Change this file instead of sconstruct or manifest files, whenever possible. + +from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries + +# Since some strings in `addon_info` are translatable, +# we need to include them in the .po files. +# Gettext recognizes only strings given as parameters to the `_` function. +# To avoid initializing translations in this module we simply import a "fake" `_` function +# which returns whatever is given to it as an argument. +from site_scons.site_tools.NVDATool.utils import _ + + +# Add-on information variables +addon_info = AddonInfo( + # add-on Name/identifier, internal for NVDA + addon_name="placeMarkers", + # Add-on summary/title, usually the user visible name of the add-on + # Translators: Summary/title for this add-on + # to be shown on installation and add-on information found in add-on store + addon_summary=_("Place markers"), + # Add-on description + # Translators: Long description to be shown for this add-on on add-on information from add-on store + addon_description=_("Add-on for setting place markers on specific virtual documents"), + # version + addon_version="50.0.0", + # Brief changelog for this version + # Translators: what's new content for the add-on version to be shown in the add-on store + addon_changelog=_("* Compatible with NVDA 2026.1."), + # Author(s) + addon_author="Noelia , Chris ", + # URL for the add-on documentation support + addon_url="https://github.com/nvdaes/placeMarkers", + # URL for the add-on repository where the source code can be found + addon_sourceURL=None, + # Documentation file name + addon_docFileName="readme.html", + # Minimum NVDA version supported (e.g. "2019.3.0", minor version is optional) + addon_minimumNVDAVersion="2026.1", + # Last NVDA version supported/tested (e.g. "2024.4.0", ideally more recent than minimum version) + addon_lastTestedNVDAVersion="2026.1", + # Add-on update channel (default is None, denoting stable releases, + # and for development releases, use "dev".) + # Do not change unless you know what you are doing! + addon_updateChannel=None, + # Add-on license such as GPL 2 + addon_license=None, + # URL for the license document the ad-on is licensed under + addon_licenseURL=None, +) + +# Define the python files that are the sources of your add-on. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +# For example to include all files with a ".py" extension from the "globalPlugins" dir of your add-on +# the list can be written as follows: +# pythonSources = ["addon/globalPlugins/*.py"] +# For more information on SCons Glob expressions please take a look at: +# https://scons.org/doc/production/HTML/scons-user/apd.html +pythonSources: list[str] = ["addon/globalPlugins/*.py", "addon/globalPlugins/placeMarkers/*.py"] + +# Files that contain strings for translation. Usually your python sources +i18nSources: list[str] = pythonSources + ["buildVars.py"] + +# Files that will be ignored when building the nvda-addon file +# Paths are relative to the addon directory, not to the root directory of your addon sources. +# You can either list every file (using ""/") as a path separator, +# or use glob expressions. +excludedFiles: list[str] = [] + +# Base language for the NVDA add-on +# If your add-on is written in a language other than english, modify this variable. +# For example, set baseLanguage to "es" if your add-on is primarily written in spanish. +# You must also edit .gitignore file to specify base language files to be ignored. +baseLanguage: str = "en" + +# Markdown extensions for add-on documentation +# Most add-ons do not require additional Markdown extensions. +# If you need to add support for markup such as tables, fill out the below list. +# Extensions string must be of the form "markdown.extensions.extensionName" +# e.g. "markdown.extensions.tables" to add tables. +markdownExtensions: list[str] = [] + +# Custom braille translation tables +# If your add-on includes custom braille tables (most will not), fill out this dictionary. +# Each key is a dictionary named according to braille table file name, +# with keys inside recording the following attributes: +# displayName (name of the table shown to users and translatable), +# contracted (contracted (True) or uncontracted (False) braille code), +# output (shown in output table list), +# input (shown in input table list). +brailleTables: BrailleTables = {} + +# Custom speech symbol dictionaries +# Symbol dictionary files reside in the locale folder, e.g. `locale\en`, and are named `symbols-.dic`. +# If your add-on includes custom speech symbol dictionaries (most will not), fill out this dictionary. +# Each key is the name of the dictionary, +# with keys inside recording the following attributes: +# displayName (name of the speech dictionary shown to users and translatable), +# mandatory (True when always enabled, False when not. +symbolDictionaries: SymbolDictionaries = {} diff --git a/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml b/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml new file mode 100644 index 0000000..aa8752d --- /dev/null +++ b/tests/e2e/template/fixtures/placeMarkers-modern/pyproject.toml @@ -0,0 +1,212 @@ +[build-system] +requires = ["setuptools~=80.9", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +dependencies = [ + # Build add-on + "scons==4.10.1", + "Markdown==3.10", + # Translations management + "requests==2.32.5", + "nh3==0.3.2", + "crowdin-api-client==1.24.1", + "lxml==6.0.2", + "mdx_truly_sane_lists==1.3", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + # Lint + "uv==0.9.11", + "ruff==0.14.5", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", +] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = ".venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + "site_scons", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. diff --git a/tests/e2e/template/test_sanity.py b/tests/e2e/template/test_sanity.py deleted file mode 100644 index 2d8bb51..0000000 --- a/tests/e2e/template/test_sanity.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (C) 2026 NV Access Limited, Abdel -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -"""Sanity test suite for verifying end-to-end test execution.""" - -import tempfile -import unittest -from pathlib import Path - - -class TestE2ESanity(unittest.TestCase): - """Minimal sanity test suite to verify E2E test runner and environment setup.""" - - def test_e2e_runner_handles_passing_tests(self): - """Ensure that temporary directories and basic file operations work for E2E tests.""" - with tempfile.TemporaryDirectory() as temp_dir: - test_path = Path(temp_dir) / "fixture_test.txt" - test_path.write_text("E2E environment check", encoding="utf-8") - - self.assertTrue(test_path.exists()) - self.assertEqual(test_path.read_text(encoding="utf-8"), "E2E environment check") - - @unittest.expectedFailure - def test_e2e_runner_handles_failing_tests(self): - """Ensure that the E2E test runner correctly detects failing integration tests. - - Marked with @expectedFailure so CI remains green while demonstrating - failure detection in E2E tests. - """ - self.assertTrue(False) \ No newline at end of file diff --git a/tests/e2e/template/test_syncAddonTool.py b/tests/e2e/template/test_syncAddonTool.py new file mode 100644 index 0000000..35df079 --- /dev/null +++ b/tests/e2e/template/test_syncAddonTool.py @@ -0,0 +1,408 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""End-to-end test suite for syncAddonTool CLI execution, modes, options, packaging, and buildVars migrations.""" + +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +FIXTURES_DIR: Path = Path(__file__).parent / "fixtures" + + +class TestSyncAddonToolE2E(unittest.TestCase): + """Test cases for verifying syncAddonTool CLI interface, synchronization modes, options, PyInstaller packaging, and buildVars migrations.""" + + def test_cli_help_output(self) -> None: + """Ensure the synchronization tool displays help instructions correctly via CLI using -h / --help.""" + helpFlag: str + for helpFlag in ["--help", "-h"]: + with self.subTest(flag=helpFlag): + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + helpFlag, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + self.assertIn("--addon-dir", result.stdout) + self.assertIn("--template-dir", result.stdout) + self.assertIn("--dry-run", result.stdout) + self.assertIn("--skip-backup", result.stdout) + + def test_standard_mode_run_inside_addon_directory(self) -> None: + """Test standard execution mode: running the tool directly from inside clipContentsDesigner root without -ad.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + projectRoot: Path = Path(__file__).parents[3] + toolPackageSrc: Path = projectRoot / "syncAddonTool" + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + # Embed syncAddonTool package inside the addon directory to allow standalone execution without -ad + shutil.copytree(toolPackageSrc, addonPath / "syncAddonTool", dirs_exist_ok=True) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + ], + cwd=str(addonPath), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Standard mode failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + buildVarsFile: Path = addonPath / "buildVars.py" + if buildVarsFile.exists(): + content: str = buildVarsFile.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + + def test_target_directory_mode_with_external_ad(self) -> None: + """Test target directory mode: running the tool specifying -ad for clipContentsDesigner from an external workspace.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + projectRoot: Path = Path(__file__).parents[3] + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + "-ad", + str(addonPath), + ], + cwd=str(projectRoot), + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"External -ad mode failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + buildVarsFile: Path = addonPath / "buildVars.py" + if buildVarsFile.exists(): + content: str = buildVarsFile.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + + def test_local_template_dir_with_td(self) -> None: + """Ensure the -td / --template-dir option successfully synchronizes using a local template reference.""" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not templateFixture.exists() or not addonFixture.exists(): + self.skipTest("Template or clipContentsDesigner add-on fixture not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(addonFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + + def test_dry_run_flag_dr(self) -> None: + """Ensure both --dry-run and short flag -dr simulate updates without altering files or creating backups.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + dryRunFlag: str + for dryRunFlag in ["--dry-run", "-dr"]: + with self.subTest(flag=dryRunFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + dryRunFlag, + "-ad", + str(addonPath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + backups: list[Path] = list(addonPath.parent.glob("*_bak_*")) + self.assertEqual(len(backups), 0) + + def test_skip_backup_flag_s(self) -> None: + """Ensure both --skip-backup and short flag -s prevent the creation of a timestamped backup directory.""" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not templateFixture.exists() or not addonFixture.exists(): + self.skipTest("Template or clipContentsDesigner add-on fixture not found.") + + skipBackupFlag: str + for skipBackupFlag in ["--skip-backup", "-s"]: + with self.subTest(flag=skipBackupFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(addonFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + skipBackupFlag, + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + backups: list[Path] = list(addonPath.parent.glob("*_bak_*")) + self.assertEqual(len(backups), 0, f"Expected no backup directory when {skipBackupFlag} is passed") + + def test_verbose_logging_flag_v(self) -> None: + """Ensure both --verbose and short flag -v enable detailed debug logging in console output.""" + addonFixture: Path = FIXTURES_DIR / "clipContentsDesigner" + if not addonFixture.exists(): + self.skipTest("Fixture clipContentsDesigner not found.") + + verboseFlag: str + for verboseFlag in ["--verbose", "-v"]: + with self.subTest(flag=verboseFlag): + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "clipContentsDesigner" + shutil.copytree(addonFixture, addonPath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "--dry-run", + verboseFlag, + "-ad", + str(addonPath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0) + combinedOutput: str = result.stdout + result.stderr + self.assertIn("DEBUG", combinedOutput) + + def test_e2e_legacy_buildvars_migration(self) -> None: + """Ensure legacy buildVars.py from dayOfTheWeek-legacy fixture is correctly merged via CLI execution.""" + legacyFixture: Path = FIXTURES_DIR / "dayOfTheWeek-legacy" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + if not legacyFixture.exists() or not templateFixture.exists(): + self.skipTest("Fixture dayOfTheWeek-legacy or addonTemplate not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "dayOfTheWeek" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(legacyFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Legacy buildVars migration E2E failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + projBvPath: Path = addonPath / "buildVars.py" + self.assertTrue(projBvPath.exists()) + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("addon_name", content) + self.assertIn("addon_version", content) + + def test_e2e_modern_buildvars_speech_dictionaries(self) -> None: + """Ensure modern buildVars.py from placeMarkers-modern fixture gets missing speechDictionaries injected via CLI.""" + modernFixture: Path = FIXTURES_DIR / "placeMarkers-modern" + templateFixture: Path = FIXTURES_DIR / "addonTemplate" + if not modernFixture.exists() or not templateFixture.exists(): + self.skipTest("Fixture placeMarkers-modern or addonTemplate not found.") + + with tempfile.TemporaryDirectory() as tempDir: + tempPath: Path = Path(tempDir) + addonPath: Path = tempPath / "placeMarkers" + templatePath: Path = tempPath / "localTemplate" + shutil.copytree(modernFixture, addonPath) + shutil.copytree(templateFixture, templatePath) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--no-project", + "--with", + "tomlkit", + "python", + "-m", + "syncAddonTool", + "-ad", + str(addonPath), + "-td", + str(templatePath), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual( + result.returncode, + 0, + f"Modern buildVars speechDictionaries E2E failed.\nSTDOUT:\n{result.stdout}\nSTDERR:\n{result.stderr}", + ) + + projBvPath: Path = addonPath / "buildVars.py" + self.assertTrue(projBvPath.exists()) + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("SpeechDictionaries", content) + + def test_pyinstaller_executable_build(self) -> None: + """Verify that the standalone executable can be successfully built using PyInstaller and uv.""" + specPath: Path = Path("syncAddonTool/syncAddonTool.spec") + if not specPath.exists(): + self.skipTest("syncAddonTool/syncAddonTool.spec not found in repository.") + + # Keep packaging outputs separate from existing developer artifacts. + tempDirObj: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory() + self.addCleanup(tempDirObj.cleanup) + tempPath: Path = Path(tempDirObj.name) + + result: subprocess.CompletedProcess[str] = subprocess.run( + [ + "uv", + "run", + "--with", + "pyinstaller", + "pyinstaller", + "syncAddonTool/syncAddonTool.spec", + "--noconfirm", + "--workpath", + str(tempPath / "build"), + "--distpath", + str(tempPath / "dist"), + ], + capture_output=True, + text=True, + check=False, + ) + self.assertEqual(result.returncode, 0, f"PyInstaller build failed: {result.stderr}") + + expectedExe: Path = tempPath / "dist" / "syncAddonTool.exe" + self.assertTrue(expectedExe.exists(), f"Expected {expectedExe} to be generated") + + +if __name__ == "__main__": + unittest.main() 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..a44ed49 --- /dev/null +++ b/tests/unit/template/test_syncAddonTool.py @@ -0,0 +1,354 @@ +# 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 testMergeBuildvarsPreservesQuoteStyleFromFixture(self) -> None: + """Ensure mergeBuildvarsFile preserves single or double quote style based on the template fixture.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # 1. Load legacy and template buildVars fixtures + legacyFixture: Path = FIXTURES_DIR / "legacyBuildVars.py" + projBvPath.write_text(legacyFixture.read_text(encoding="utf-8"), encoding="utf-8") + + templateFixture: Path = FIXTURES_DIR / "templateBuildVars.py" + tplBvPath.write_text(templateFixture.read_text(encoding="utf-8"), encoding="utf-8") + + # 2. Extract metadata and merge template into project file + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + status: str = mergeBuildvarsFile( + projBvPath, tplBvPath, metadata, globalVars, dryRun=False + ) + + self.assertEqual(status, "merged & structured (AST verified)") + + content: str = projBvPath.read_text(encoding="utf-8") + + # 3. Verify quote style preservation based on templateBuildVars.py + self.assertIn('addon_name="myAddon"', content) + self.assertIn('addon_summary=_("My Test Addon")', content) + + def testSetupAddonMergeIgnore(self) -> None: + """Verify bootstrapping of .addonmergeignore from template to add-on directory. + + 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 testMergeBuildvarsAutoImportsGlob(self) -> None: + """Ensure 'from glob import glob' is automatically added if merged buildVars uses the glob module.""" + with tempfile.TemporaryDirectory() as tempDir: + projBvPath: Path = Path(tempDir) / "buildVars.py" + tplBvPath: Path = Path(tempDir) / "template_buildVars.py" + + # Legacy buildVars using glob function without import in template + projBvPath.write_text( + 'pythonSources = glob("*.py")\n', + encoding="utf-8", + ) + + tplBvPath.write_text( + 'pythonSources: list[str] = []\n', + encoding="utf-8", + ) + + metadata: dict + globalVars: dict + metadata, globalVars = extractBuildvarsMetadata(projBvPath) + mergeBuildvarsFile(projBvPath, tplBvPath, metadata, globalVars, dryRun=False) + + content: str = projBvPath.read_text(encoding="utf-8") + self.assertIn("from glob import glob", content) + + def testFixTomlIndentation(self) -> None: + """Ensure 4-space indentations are converted to tabs across TOML blocks.""" + inputToml: str = ( + '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 8f54872..a9b8916 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"