diff --git a/.github/ISSUE_TEMPLATE/01-bug_report.yaml b/.github/ISSUE_TEMPLATE/01-bug_report.yaml new file mode 100644 index 0000000..d8a7896 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug_report.yaml @@ -0,0 +1,170 @@ +name: Bug report +description: Create a bug report to help us improve this add-on +type: Bug +labels: [bug] + +body: + - type: markdown + attributes: + value: | + ### Attention! + + Please read the instructions carefully before filling out this template. + Issues may be closed if required information is missing. + Attach an NVDA log file whenever possible to help us diagnose the problem faster. + + - type: textarea + id: summary + attributes: + label: Brief summary + validations: + required: true + + - type: textarea + id: reproSteps + attributes: + label: Steps to reproduce + description: | + A clear and concise list of steps to reproduce the problem. + Include any sample files or links required to reproduce the issue. + placeholder: | + Example: + 1. Press NVDA+control+X to open add-on dialog + 2. Select "Settings" option + 3. Press Enter + validations: + required: true + + - type: textarea + id: actualBehavior + attributes: + label: Actual behavior + description: | + Describe what actually happens when these steps are taken. + Include verbatim speech/braille viewer output if relevant, or error sounds heard. + placeholder: | + Example: + An NVDA error sound is heard. + NVDA Speech: "error" + validations: + required: true + + - type: textarea + id: expectedBehavior + attributes: + label: Expected behavior + description: | + Describe what you expected to happen when these steps are taken. + placeholder: | + Example: + The settings dialog should open without any error sound. + validations: + required: true + + - type: textarea + id: attachedFiles + attributes: + label: NVDA logs and other attachments + description: | + In most cases an NVDA log file is incredibly helpful when trying to understand or fix an issue. + Please attach your NVDA log file or any relevant screenshots/files here. + + - type: markdown + attributes: + value: | + ### System Configuration + + Please provide information about your system and add-on setup. + + - type: input + id: addonVersion + attributes: + label: Add-on version + description: | + Which version of this add-on are you using? + You can check this in NVDA's Add-on Store. + validations: + required: true + + - type: dropdown + id: nvdaType + attributes: + label: NVDA type + description: Is this reproduced with NVDA installed/portable/running from source? + multiple: true + options: ["installed copy", "portable copy", "installer/launcher copy", "source copy"] + validations: + required: true + + - type: input + id: nvdaVersion + attributes: + label: NVDA version + description: | + Which version(s) of NVDA can you reproduce this issue with? + This can be copied from the NVDA About dialog (NVDA menu, Help, About). + validations: + required: true + + - type: textarea + id: otherNvdaVersions + attributes: + label: Have you tried any other versions of NVDA? If so, please report their behaviors. + description: | + Knowing whether this issue occurs in previous releases helps us determine if a change in NVDA or this add-on introduced the problem. + + - type: input + id: winVersion + attributes: + label: Windows version + description: | + The version of Windows you are using. + placeholder: | + Example: Windows 11 Version 25H2 Build 26220.8925 + validations: + required: true + + - type: textarea + id: otherVersions + attributes: + label: Name and version of other software in use when reproducing the issue + placeholder: | + Example: Chrome version 126.0.6478.127 + + - type: textarea + id: otherSysInfo + attributes: + label: Other information about your system + + - type: markdown + attributes: + value: | + ### Troubleshooting steps + + - type: dropdown + id: restart + validations: + required: true + attributes: + label: Does the issue still occur after restarting your computer? + options: [ + "I have not restarted my computer", + "I have restarted my computer and the issue still occurs", + "Restarting my computer fixes the issue temporarily, but it comes back later", + "Restarting my computer fixed the issue", + ] + + - type: dropdown + id: addonConflicts + validations: + required: true + attributes: + label: Does the issue still occur when other NVDA add-ons are disabled (keeping only this add-on active)? + description: | + Issues can sometimes be caused by conflicts between multiple add-ons. + options: [ + "I have not tested with other add-ons disabled", + "I have disabled other add-ons and the issue still occurs with only this add-on enabled", + "The issue no longer occurs when other add-ons are disabled", + "I do not have any other NVDA add-ons installed", + ] diff --git a/.github/ISSUE_TEMPLATE/02-feature_request.yaml b/.github/ISSUE_TEMPLATE/02-feature_request.yaml new file mode 100644 index 0000000..654e65d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature_request.yaml @@ -0,0 +1,44 @@ +name: Feature request +description: Suggest an idea or improvement for this add-on +type: Feature +labels: [enhancement] + +body: + - type: markdown + attributes: + value: | + ### Feature Request + + Thank you for helping us improve this add-on! Please describe the feature or enhancement you would like to see. + + - type: textarea + id: problem + validations: + required: true + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem or limitation is. + + - type: textarea + id: solution + validations: + required: true + attributes: + label: Describe the solution you'd like + description: | + A clear and concise description of what you want to happen. + If you have technical ideas for implementation, feel free to include them. + + - type: textarea + id: altSolution + attributes: + label: Describe alternatives you've considered + description: | + A clear and concise description of any alternative solutions or workarounds you've considered. + + - type: textarea + id: context + attributes: + label: Additional context + description: | + Add any other context or attachments for the feature request here (such as target application versions or example documents). diff --git a/.github/ISSUE_TEMPLATE/03-developer_facing_changes.yaml b/.github/ISSUE_TEMPLATE/03-developer_facing_changes.yaml new file mode 100644 index 0000000..9c09ccd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/03-developer_facing_changes.yaml @@ -0,0 +1,48 @@ +name: Developer facing changes +description: Maintenance tasks, API changes, and code changes which are not user-facing changes +type: Task +labels: [maintenance] + +body: + - type: markdown + attributes: + value: | + This template is intended for developers to document improvements or maintenance to this add-on's codebase that do not involve user-facing changes. + This may include API refactoring, technical debt removal, CI updates, and maintenance tasks. + + If this involves a user-facing change (e.g., a feature or bug), please use one of our other templates. + + - type: textarea + id: relatedIssues + attributes: + label: Related issues, PRs or discussions + validations: + required: false + + - type: textarea + id: currentState + attributes: + label: What is the current state of the codebase? + validations: + required: true + + - type: textarea + id: whyChanges + attributes: + label: Why are changes required? + validations: + required: true + + - type: textarea + id: technicalChanges + attributes: + label: What technical changes are required? + validations: + required: true + + - type: textarea + id: risksIssues + attributes: + label: Are there potential risks or issues with the proposed implementation? + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/11-advanced_bug_report.md b/.github/ISSUE_TEMPLATE/11-advanced_bug_report.md new file mode 100644 index 0000000..1169e6e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/11-advanced_bug_report.md @@ -0,0 +1,28 @@ +--- +name: Advanced Bug Report (Markdown) +about: Free-form Markdown bug report template for experienced developers +title: '' +labels: 'bug' +assignees: '' +--- + +## Summary + + +## Steps to Reproduce +1. +2. + +## Actual Behavior + + +## Expected Behavior + + +## Environment +- **Add-on Version:** +- **NVDA Version:** +- **Windows Version:** + +## Logs & Attachments + diff --git a/.github/ISSUE_TEMPLATE/12-advanced_feature_request.md b/.github/ISSUE_TEMPLATE/12-advanced_feature_request.md new file mode 100644 index 0000000..fe55994 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/12-advanced_feature_request.md @@ -0,0 +1,19 @@ +--- +name: Advanced Feature Request (Markdown) +about: Free-form Markdown feature request template for experienced developers +title: '' +labels: 'enhancement' +assignees: '' +--- + +## Motivation / Problem Statement + + +## Proposed Solution + + +## Alternatives Considered + + +## Implementation Details / Context + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..0086358 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: true diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py new file mode 100644 index 0000000..9d03dc6 --- /dev/null +++ b/.github/scripts/checkTranslation.py @@ -0,0 +1,144 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import sys +import os +from crowdin_api import CrowdinClient + + +def findFileId(client: CrowdinClient, projectId: int, baseTarget: str, searchExt: str) -> int | None: + """ + Iterates through all project files (using pagination) to find the ID of the source file matching the target name and extension. + + :param client: The Crowdin API client instance. + :param projectId: The ID of the Crowdin project. + :param base_target: The base name of the file (e.g., 'myAddon). + :param search_ext: The extension to look for (e.g., '.pot'). + :return: The file ID if found, otherwise None. + """ + offset = 0 + limit = 100 + + while True: + resp = client.source_files.list_files( + projectId=projectId, + limit=limit, + offset=offset, + ) + + data = resp["data"] + for f in data: + path_crowdin = f["data"]["path"].lower() + # Check if the path ends with addon_id.pot or addon_id.xliff. + if path_crowdin.endswith(f"{baseTarget}{searchExt}"): + fileId = f["data"]["id"] + print(f"DEBUG: Match found: {path_crowdin} (ID: {fileId})") + return fileId + + if len(data) < limit: + break + + offset += limit + + return None + + +def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: + """ + Retrieves the translation progress score for a specific language and file. + Handles pagination for both file listing and language status. + + :param fileNameToSearch: The local path or name of the file to check. + :param langId: The language code (e.g., 'fr' or 'pt_BR'). + :return: The translation ratio between 0.0 and 1.0. + """ + token = os.environ.get("crowdinAuthToken") + projectIdEnv = os.environ.get("CROWDIN_PROJECT_ID") + + if not token or not projectIdEnv: + print("ERROR: Missing environment variables 'crowdinAuthToken' or 'CROWDIN_PROJECT_ID'.") + return 0.0 + + client = CrowdinClient(token=token) + projectId = int(projectIdEnv) + + try: + # Clean and prepare search patterns. + # Example: 'addon/locale/fr/LC_MESSAGES/myAddon.po' -> base_target: 'myAddon'. + baseTarget = fileNameToSearch.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() + extTarget = fileNameToSearch.split(".")[-1].lower() + + # On Crowdin, the source for a .po file is usually a .pot file. + searchExt = ".pot" if extTarget == "po" else f".{extTarget}" + + print(f"DEBUG: Searching for source file: {baseTarget}{searchExt}") + + fileId = findFileId(client, projectId, baseTarget, searchExt) + + if fileId is None: + print(f"WARNING: File '{baseTarget}{searchExt}' not found on Crowdin.") + return 0.0 + + # Pagination for translation status (Progress). + offset = 0 + limit = 100 + + while True: + resp = client.translation_status.get_file_progress( + projectId=projectId, + fileId=fileId, + limit=limit, + offset=offset, + ) + + data = resp["data"] + for item in data: + langApi = item["data"]["languageId"] + + # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API). + # Also handles underscore to dash conversion for Crowdin compatibility + if langApi.lower().startswith(langId.lower().replace("_", "-")): + progress = float(item["data"]["translationProgress"]) + return progress + + # Check pagination total. + total = resp["pagination"]["totalCount"] + if offset + limit >= total: + break + offset += limit + + print(f"DEBUG: Language '{langId}' not found in progress list for this file.") + return 0.0 + + except Exception as e: + print(f"API ERROR: {e}") + return 0.0 + + +def main(): + if len(sys.argv) < 3: + print("Usage: python checkTranslation.py ") + sys.exit(2) + + input_file = sys.argv[1] + lang = sys.argv[2] + + score = getScoreFromApi(input_file, lang) + + # Output formatted for capture by the PowerShell script. + print(f"translationRatio={score}") + + # Identify extension to provide a specific score label. + ext = input_file.lower().split(".")[-1] + if ext == "md": + print(f"mdScore={score}") + elif ext == "xliff": + print(f"xliffScore={score}") + else: + # Default to poScore for .po and other localization files. + print(f"poScore={score}") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 new file mode 100644 index 0000000..8e7bb92 --- /dev/null +++ b/.github/scripts/crowdinSync.ps1 @@ -0,0 +1,227 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = 'Stop' + +# Git configuration for automated commits +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" + +$rawAddonId = $env:ADDON_ID +if ([string]::IsNullOrWhiteSpace($rawAddonId)) { + Write-Error "Failed to get addon ID." + exit 1 +} +$addonId = $rawAddonId.Trim() + +# --- STEP 1: PREPARATION AND SOURCE UPDATE --- + +$xliffFile = "./$addonId.xliff" +$mdFile = "./readme.md" + +if (Test-Path $mdFile) { + if (Test-Path $xliffFile) { + $tempXliff = [System.IO.Path]::GetTempFileName() + try { + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "DEBUG: Updating XLIFF source based on readme.md..." + ./l10nUtil.exe md2xliff $mdFile $xliffFile -o $tempXliff + } + finally { + if (Test-Path $tempXliff) { + Remove-Item $tempXliff -Force + } + } + } + else { + Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." + ./l10nUtil.exe md2xliff $mdFile $xliffFile + } +} + +# Update POT file (addon interface) +uv run scons pot +$potFile = "$addonId.pot" + +# --- STEP 2: UPLOAD SOURCES TO CROWDIN --- + +if (Test-Path $potFile) { + Write-Host "DEBUG: Uploading updated POT source to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$potFile" -c $env:L10N_UTIL_CONFIG +} + +if (Test-Path $xliffFile) { + Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$xliffFile" -c $env:L10N_UTIL_CONFIG + + git add "$xliffFile" + git diff --staged --quiet + + if ($LASTEXITCODE -ne 0) { + git commit -m "Update $xliffFile for $addonId" + } +} + +# --- STEP 3: EXPORT AND PROCESS TRANSLATIONS --- + +Write-Host "DEBUG: Exporting translations from Crowdin..." +./l10nUtil.exe exportTranslations -o _addonL10n -c $env:L10N_UTIL_CONFIG + +# Ensure base directories exist +New-Item -ItemType Directory -Force -Path addon/locale | Out-Null +New-Item -ItemType Directory -Force -Path addon/doc | Out-Null + +# Load language mappings for Crowdin API calls +$languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json + +foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + + $langCode = $dir.Name + + if ($langCode -eq "en") { + continue + } + + # --- Identify codes + + $crowdinLang = $null + + if ($languageMappings.PSObject.Properties.Name -contains $langCode) { + $crowdinLang = $languageMappings."$langCode" + } + + if (-not $crowdinLang) { + $crowdinLang = $langCode.Replace('_', '-') + } + + Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan + + # Paths + + $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" + $remotePo = Join-Path $dir.FullName "$addonId.po" + + $localMdDir = "addon/doc/$langCode" + $localMd = "$localMdDir/readme.md" + + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + # --- 3.1 PO FILE PROCESSING --- + $poImported = $false + $scorePo = 0.0 + $threshold = $env:MIN_PERCENTAGE_TRANSLATED + + if (Test-Path $remotePo) { + + Write-Host "DEBUG: Evaluating Remote PO score..." + + $res = uv run python .github/scripts/checkTranslation.py "$addonId.po" $crowdinLang + + $scorePo = [double]( + ($res | Select-String "poScore=").ToString().Split("=")[1] + ) + + Write-Host "DEBUG: PO Score -> $scorePo" + + if ($scorePo -ge $threshold) { + + Write-Host "SUCCESS: Remote PO is above threshold. Importing to $localPoPath" + + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + + Move-Item $remotePo $localPoPath -Force + + $poImported = $true + } + else { + + Write-Host "WARNING: Remote PO score is below threshold ($threshold)." + } + } + + if (-not $poImported -and (Test-Path $localPoPath)) { + + Write-Host "ACTION: Uploading local legacy PO to Crowdin ($crowdinLang) as fallback." + + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.po" $localPoPath -c $env:L10N_UTIL_CONFIG + } + + # --- 3.2 DOCUMENTATION PROCESSING (XLIFF ONLY) --- + + $scoreXliff = 0.0 + + if (Test-Path $remoteXliff) { + + Write-Host "DEBUG: Evaluating Remote XLIFF score..." + + $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $crowdinLang + + $scoreXliff = [double]( + ($res | Select-String "xliffScore=").ToString().Split("=")[1] + ) + } + else { + Write-Host "DEBUG: No remote XLIFF file found for this language." + } + + Write-Host "DEBUG: XLIFF Score -> $scoreXliff" + + $threshold = $env:MIN_PERCENTAGE_TRANSLATED + $docImported = $false + + if ($scoreXliff -ge $threshold) { + + if (!(Test-Path $localMdDir)) { + New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null + } + + Write-Host "SUCCESS: Importing documentation from XLIFF ($langCode)..." + + ./l10nUtil.exe xliff2md $remoteXliff $localMd + + $docImported = $true + } + else { + + Write-Host "WARNING: Remote XLIFF score is below threshold ($threshold)." + } + + # No Markdown fallback upload anymore. + # XLIFF is now the single translation source in Crowdin. +} + +# --- STEP 4: COMMIT UPDATED TRANSLATIONS --- + +git add addon/locale addon/doc + +git diff --staged --quiet + +if ($LASTEXITCODE -ne 0) { + + git commit -m "Update translations for $addonId from Crowdin (Automatic Sync)" + + Write-Host "SUCCESS: Translations committed." +} +else { + + Write-Host "DEBUG: No changes in translations to commit." +} + +# Push all generated commits after successful Crowdin synchronization + +$pushOutput = git push 2>&1 + +$repository = $env:GITHUB_REPOSITORY + +Write-Host $pushOutput + +if ($LASTEXITCODE -ne 0) { + + Write-Host "ERROR: Failed to push commits to $repository." +} +elseif ($pushOutput -match "Everything up-to-date") { + + Write-Host "INFO: No new commits needed to be pushed." +} +else { + + Write-Host "SUCCESS: New commits successfully pushed to $repository." +} diff --git a/.github/scripts/languageMappings.json b/.github/scripts/languageMappings.json new file mode 100644 index 0000000..42b51d7 --- /dev/null +++ b/.github/scripts/languageMappings.json @@ -0,0 +1,15 @@ +{ + "af_ZA": "af", + "de_CH": "de-CH", + "es": "es-ES", + "es_CO": "es-CO", + "nb_NO": "nb", + "ne": "ne-NP", + "nn_NO": "nn-NO", + "pt_PT": "pt-PT", + "pt_BR": "pt-BR", + "sr": "sr-CS", + "zh_CN": "zh-CN", + "zh_HK": "zh-HK", + "zh_TW": "zh-TW" +} diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py new file mode 100644 index 0000000..81385ae --- /dev/null +++ b/.github/scripts/setOutputs.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025-2026 NV Access Limited, Noelia Ruiz Martínez +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os +import sys + +sys.path.insert(0, os.getcwd()) +import buildVars + + +def main(): + addonId = buildVars.addon_info["addon_name"] + name = "addonId" + value = addonId + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + _ = f.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index ab0bba2..5fcfdc8 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -10,42 +10,48 @@ on: branches: [ main, master ] workflow_dispatch: + workflow_call: jobs: build: + # Building the add-on template as an add-on does not make sense (and fails). + # Do not modify this repo name with your own one! (should remain the template) + if: github.repository != 'nvaccess/addonTemplate' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 - - - run: echo -e "pre-commit\nscons\nmarkdown">requirements.txt - - - name: Set up Python - uses: actions/setup-python@v6 + - name: Checkout repo + uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@v10.0.1 with: - python-version: 3.11 - cache: 'pip' - + enable-cache: true - name: Install dependencies run: | - python -m pip install --upgrade pip wheel - pip install -r requirements.txt sudo apt-get update -y sudo apt-get install -y gettext + uv sync - name: Code checks - run: export SKIP=no-commit-to-branch; pre-commit run --all + run: export PREK_SKIP=no-commit-to-branch; uv run prek run --all-files - name: building addon - run: scons && scons pot + run: uv run scons && uv run scons pot - - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v7 with: name: packaged_addon path: | ./*.nvda-addon + archive: false + + - uses: actions/upload-artifact@v7 + with: + name: translation_template + path: | ./*.pot + archive: false upload_release: runs-on: ubuntu-latest @@ -54,9 +60,12 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v5 - - name: download releases files - uses: actions/download-artifact@v6 + - uses: actions/checkout@v7 + - name: download all artifacts + uses: actions/download-artifact@v8 + with: + path: . + merge-multiple: true - name: Display structure of downloaded files run: ls -R - name: Calculate sha256 @@ -65,7 +74,7 @@ jobs: sha256sum *.nvda-addon >> changelog.md - name: Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: | *.nvda-addon diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml new file mode 100644 index 0000000..8a1d574 --- /dev/null +++ b/.github/workflows/crowdinL10n.yml @@ -0,0 +1,64 @@ +name: Crowdin l10n + +on: + workflow_dispatch: + schedule: + # Every Monday at 00:00 UTC + - cron: '0 0 * * 1' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + GH_TOKEN: ${{ github.token }} + CROWDIN_PROJECT_ID: ${{ vars.CROWDIN_PROJECT_ID || 780748 }} + L10N_UTIL_CONFIG: ${{ vars.L10N_UTIL_CONFIG || 'addon' }} + MIN_PERCENTAGE_TRANSLATED: ${{ vars.MIN_PERCENTAGE_TRANSLATED || 50 }} + +jobs: + crowdinSync: + runs-on: windows-latest + permissions: + contents: write + + steps: + - name: Random startup delay (0-5 minutes) + if: github.event_name == 'schedule' + shell: pwsh + run: | + $delaySeconds = Get-Random -Minimum 0 -Maximum 301 + Write-Host "Sleeping for $delaySeconds seconds..." + Start-Sleep -Seconds $delaySeconds + - name: Checkout add-on + uses: actions/checkout@v7 + with: + submodules: true + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version-file: ".python-version" + - name: Install uv + uses: astral-sh/setup-uv@v10.0.1 + - name: Install dependencies + run: uv sync + - name: Install gettext + run: | + choco install -y gettext + # Add gettext to PATH for current and future steps + $gettextPath = "C:\Program Files\gettext-iconv\bin" + echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Get add-on info + id: getAddonInfo + shell: pwsh + run: uv run ./.github/scripts/setOutputs.py + - name: Download l10nUtil + run: | + gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" + - name: Download translations from Crowdin + if: ${{ env.crowdinAuthToken }} + shell: pwsh + env: + ADDON_ID: ${{ steps.getAddonInfo.outputs.addonId }} + run: ./.github/scripts/crowdinSync.ps1 diff --git a/.github/workflows/unitTests.yml b/.github/workflows/unitTests.yml new file mode 100644 index 0000000..d53e3d8 --- /dev/null +++ b/.github/workflows/unitTests.yml @@ -0,0 +1,26 @@ +name: Unit Tests + +on: + push: + branches: [ master ] + pull_request: + +jobs: + unit-tests: + name: Run Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + + - name: Install uv + uses: astral-sh/setup-uv@v10.0.1 + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: "3.13" + + - name: Run test suite via unittest + run: uv run python -m unittest discover -s tests -v diff --git a/.gitignore b/.gitignore index 0be8af1..a6ccee5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ manifest.ini *.nvda-addon .sconsign.dblite /[0-9]*.[0-9]*.[0-9]*.json +*.egg-info diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml deleted file mode 100644 index dd7a9d6..0000000 --- a/.pre-commit-config.yaml +++ /dev/null @@ -1,7 +0,0 @@ -repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: check-ast - - id: check-case-conflict - - id: check-yaml diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/.vscode/typings/__builtins__.pyi b/.vscode/typings/__builtins__.pyi index 88febec..48cbde4 100644 --- a/.vscode/typings/__builtins__.pyi +++ b/.vscode/typings/__builtins__.pyi @@ -1,6 +1,2 @@ -def _(msg: str) -> str: - ... - - -def pgettext(context: str, message: str) -> str: - ... +def _(msg: str) -> str: ... +def pgettext(context: str, message: str) -> str: ... diff --git a/buildVars.py b/buildVars.py index c125fae..fde02e7 100644 --- a/buildVars.py +++ b/buildVars.py @@ -1,7 +1,7 @@ # Build customizations # Change this file instead of sconstruct or manifest files, whenever possible. -from site_scons.site_tools.NVDATool.typings import AddonInfo, BrailleTables, SymbolDictionaries +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. @@ -10,7 +10,6 @@ # 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 @@ -99,5 +98,14 @@ # 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. +# 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/docs/l10n/addonAuthors.md b/docs/l10n/addonAuthors.md new file mode 100644 index 0000000..2e4d1ad --- /dev/null +++ b/docs/l10n/addonAuthors.md @@ -0,0 +1,77 @@ +# Translation Guide for Authors + +## Overview + +This template supports translating add-ons with Crowdin. + +## Crowdin Project Setup + +You need a Crowdin account and an API token with permissions to manage a project. +If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): + +* **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**), or in the [NVDA Add-ons Mailing List](https://groups.io/g/nvda-addons) (**nvda-addons@groups.io**), requesting an invitation to join the project as a developer. +* **API Token:** Once invited, generate an API token in your Crowdin account settings. + +### Required Token Scope & Permissions + +To maintain minimum necessary privileges, configure your token in **Account Settings > API > New Token** with the following settings: + +1. **Project Restriction:** Under **Projects**, choose **Selected projects** and select only the NVDA add-ons community project. +1. **Scopes:** Select only these permissions: + * **`projects` (Read):** Required to retrieve project settings and configuration. + * **`source-files` (Read & Write):** Required to upload `.pot` and `.xliff` source files. + * **`translations` (Read & Write):** Required to download translated `.po` and `.xliff` files and verify translation progress. + +> **Note:** Leave all other scopes (*User*, *Webhooks*, *Screenshots*, *Reports*, *Billing*) unchecked, as they are not required by the synchronization scripts. + +## GitHub Secrets and Variables + +To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): + +1. In the **Repository secrets** section, click **New repository secret**. +1. In the **Name** field, enter `CROWDIN_TOKEN`. +1. In the **Secret** field, paste your Crowdin API token. +1. Click **Add secret** to save it. +Once added, the token will be available to your repository's workflows. + +Optionally, if you don't want to use the [Crowdin community project](https://crowdin.com/project/nvdaaddons), you can create repository variables from **Settings > Secrets and variables > Actions > Variables** by selecting the **Variables** tab and clicking **New repository variable**. + +The following repository variables are supported: + +* `CROWDIN_PROJECT_ID`: Paste the project ID of your Crowdin project. +* `L10N_UTIL_CONFIG`: The path to the YAML file containing the configuration for `l10nUtil.exe`, used by the translation scripts. +For more details, visit the [nvdaL10n repository](https://github.com/nvaccess/nvdaL10n). +* `MIN_PERCENTAGE_TRANSLATED`: Defines the minimum translation completion percentage required before a translated file is synchronized back to the repository. +The value must be between `0` and `100`. + +Examples for `MIN_PERCENTAGE_TRANSLATED`: + +* `50`: Import files that are at least 50% translated. +* `75`: Import files that are at least 75% translated. +* `100`: Import only fully translated files. + +If `MIN_PERCENTAGE_TRANSLATED` is not defined, the workflow uses a default value of `50`. + +## Infrastructure + +Ensure that your repository includes the following files (provided in this template): + +* **Workflows:** `.github/workflows/crowdinL10n.yml` +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. + +Documentation synchronization relies on the XLIFF support built into `l10nUtil.exe`. + +The `md2xliff` command is used to generate the source XLIFF file from the English `readme.md` documentation file. +Translated XLIFF files downloaded from Crowdin are then converted back to Markdown documentation using `l10nUtil.exe xliff2md`. + +## Running the Workflow + +The translation workflow will be run weekly. +Also, you can run the workflow manually from GitHub or using GitHub CLI. + +If you manage several add-ons, consider using different cron schedules for each repository. +Although the workflow includes a random startup delay to reduce collisions, concurrent Crowdin synchronization jobs may still occur. + +Documentation and interface translations are synchronized only when their translation percentage reaches the configured `MIN_PERCENTAGE_TRANSLATED` threshold. + +This validation mechanism is applied consistently to both `.po` and `.xliff` translation files. diff --git a/docs/l10n/addonTranslators.md b/docs/l10n/addonTranslators.md new file mode 100644 index 0000000..eaa4b40 --- /dev/null +++ b/docs/l10n/addonTranslators.md @@ -0,0 +1,219 @@ +# Translation Guide for Translators + +## Overview + +Many NVDA add-ons use the NVDA Add-ons Crowdin project (`nvdaaddons`) to manage translations. + +This project allows translators to contribute both interface translations and documentation translations. + +Translations are synchronized back to add-on repositories through the localization workflow provided by the NVDA Add-on Template. + +## Joining the Translation Community + +Before contributing translations, translators are encouraged to subscribe to the NVDA Translations mailing list. + +The mailing list is the primary place for discussing translation-related topics within the NVDA community. + +## NVDA Translations Mailing List + +The NVDA community maintains the NVDA Translations mailing list on Groups.io. + +This mailing list is used to: + +* Discuss translation-related topics. +* Request access to translation teams. +* Coordinate translation efforts. +* Report translation issues. +* Discuss problems affecting translation tools or workflows. + +Translators are encouraged to subscribe to the mailing list: + +https://groups.io/g/nvda-translations + +The mailing list is often the best place to ask questions, request access to a translation team, and seek assistance from other translators and project maintainers. + +## Joining the Translation Project + +To contribute translations: + +1. Create a Crowdin account. +1. Subscribe to the NVDA Translations mailing list. +1. Request access to the appropriate translation team if necessary. +1. Join the NVDA Add-ons Crowdin project. +1. Select the language you wish to translate. +1. Begin translating interface strings and documentation. + +## Translation Methods + +Translations can be performed using either the Crowdin web interface or local translation tools. + +### Crowdin Web Editor + +Crowdin provides a web-based editor that allows translators to: + +* Translate strings online. +* Review existing translations. +* Suggest improvements. +* Vote on translation proposals. + +This method does not require any additional software installation. + +### Poedit + +Many NVDA translators prefer to work locally using [Poedit](https://poedit.com/) because of its accessibility and ease of use. + +Poedit supports both: + +* Portable Object (`.po`) files used for interface translations. +* XLIFF (`.xliff`) files used for documentation translations. + +After completing translations locally, files can be uploaded back to Crowdin using [l10nUtil.exe](https://github.com/nvaccess/nvdaL10n/releases/latest/download/l10nUtil.exe). + +## Translating Interface Strings + +Interface translations are stored in Portable Object (`.po`) files. + +These files can be translated either: + +* Directly in Crowdin. +* Using Poedit. + +## Translating Documentation + +Documentation translations are stored in XLIFF (`.xliff`) files. + +These files are generated automatically from the add-on documentation. + +Documentation can be translated: + +* Directly in Crowdin. +* Using Poedit. + +When translating documentation: + +* Translate only the text content. +* Preserve placeholders and formatting. +* Do not modify the XLIFF structure manually. + +## Uploading Offline Translations + +After translating files locally, they can be uploaded to Crowdin using [l10nUtil.exe](https://github.com/nvaccess/nvdaL10n/releases/latest/download/l10nUtil.exe) + +Examples: + +```cmd +l10nUtil.exe uploadTranslationFile fr addonName.po -c addon +``` + +```cmd +l10nUtil.exe uploadTranslationFile fr addonName.xliff -c addon +``` + +Where: + +* `fr` is the Crowdin language code. +* `addonName.po` is a translated interface file. +* `addonName.xliff` is a translated documentation file. + +Once uploaded, the translations become available in Crowdin and can later be synchronized back into the add-on repository. + +## Using l10nUtil.exe + +To display the complete list of available commands: + +```cmd +l10nUtil.exe --help +``` + +or: + +```cmd +l10nUtil.exe -h +``` + +To display help for a specific command: + +```cmd +l10nUtil.exe downloadTranslationFile --help +``` + +or: + +```cmd +l10nUtil.exe downloadTranslationFile -h +``` + +Refer to the utility help output for a complete list of supported commands and options. + +## How Synchronization Works + +Translations are not immediately imported into GitHub repositories. + +The add-on maintainer runs a synchronization workflow that: + +1. Connects to the NVDA Add-ons Crowdin project. +1. Downloads completed translations. +1. Verifies their translation completion percentage. +1. Synchronizes eligible translations back into the repository. + +Depending on the repository configuration, translations may only be synchronized after reaching a minimum completion percentage. + +This threshold is controlled by the add-on maintainer. + +## Why Has My Translation Not Appeared Yet? + +Possible reasons include: + +* The synchronization workflow has not yet run. +* The required translation completion percentage has not yet been reached. +* The maintainer has temporarily disabled synchronization. +* The translation was completed after the most recent synchronization cycle. + +## Best Practices + +To improve translation quality: + +* Maintain consistency with existing terminology. +* Preserve placeholders and formatting. +* Review existing translations before introducing new terminology. +* Coordinate with other translators whenever possible. +* Test translated files locally before uploading them when possible. + +## Reporting Problems + +If you encounter translation issues: + +* Contact the add-on maintainer. +* Open an issue in the add-on repository if appropriate. +* Ask for assistance on the NVDA Translations mailing list. +* Discuss translation-related issues with the NVDA translation community. + +## Frequently Asked Questions + +### Can I translate both documentation and interface strings? + +Yes. + +The NVDA Add-ons Crowdin project supports both interface translations (`.po`) and documentation translations (`.xliff`). + +### Do I need to use the Crowdin web interface? + +No. + +Many translators work directly in Crowdin, while others prefer to use Poedit and upload their completed translations afterwards using `l10nUtil.exe`. + +Both approaches are supported by the NVDA Add-ons translation workflow. + +### Do I need access to GitHub? + +Not necessarily. + +Most translators work exclusively through Crowdin or through local translation tools combined with `l10nUtil.exe`. + +The synchronization workflow is managed by the add-on maintainer and automatically imports translations into GitHub repositories. + +### Can I force synchronization? + +No. + +Synchronization is controlled by the add-on maintainer through the localization workflow provided by the NVDA Add-on Template. diff --git a/docs/managementFromGit/updatingExistingAddons.md b/docs/managementFromGit/updatingExistingAddons.md new file mode 100644 index 0000000..4895903 --- /dev/null +++ b/docs/managementFromGit/updatingExistingAddons.md @@ -0,0 +1,247 @@ +# Integrating the add-on template in your add-on + +## Pre-requisites + +1. Create a repository, for example on GitHub, providing readme and license files. +1. Clone the repository: + + ```sh + git clone https://github.com/{repoName}.git + ``` + +1. In the folder where your add-on repository is cloned, create an `addon` submolder and store the code for your add-on. + +1. Go to the folder where your repository was cloned: + + ```sh + cd {repoFolder} + ``` + +1. Commit your changes: + + ```sh + git add . + git commit -m "Initial commit" + ``` + +1. Add the template as a remote: + + ```sh + git remote add template https://github.com/nvaccess/addonTemplate.git + ``` + +1. Fetch the add-on template: + + ```sh + git fetch template + ``` + +## Updating an Existing Add-on + +As AddonTemplate evolves, it receives improvements, bug fixes, new GitHub workflows, and build system updates. + +You can merge the latest template changes into your repository instead of manually copying updated files. + +This document explains the recommended update procedure. + +> [!NOTE] +> Updating from AddonTemplate only affects your project's infrastructure (build scripts, GitHub workflows, configuration files, etc.). It does **not** modify your add-on's source code. + +## Before you begin + +Before updating your repository: + +- Ensure your working tree is clean. + + ```sh + git status + ``` + +- Commit or stash any pending changes. + +- It is recommended to perform the update on a dedicated branch. + +If anything goes wrong before the merge commit is created, if you haven't passed the `--squash- flag, you can safely cancel the operation using: + +```sh +git merge --abort +``` + +## Adding the template repository + +If you have not already done so, add AddonTemplate as a remote: + +```sh +git remote add template https://github.com/nvaccess/AddonTemplate.git +``` + +Then fetch the latest changes: + +```sh +git fetch template +``` + +## Merging the latest template + +Merge the latest version of AddonTemplate: + +```sh +git merge template/master --allow-unrelated-histories --squash +``` + +The `--allow-unrelated-histories` option is required because your add-on repository and AddonTemplate do not share a common Git history. + +The `--squash` flags will add changes from the template as a unique commit, instead of several ones, what may be useful to keep a cleaner history on your repository. + +At this stage, Git may report merge conflicts. + +This is completely normal. + +## Understanding merge conflicts + +During the merge, Git attempts to combine the contents of both repositories automatically. + +When Git cannot determine which version should be kept, it reports a merge conflict. + +A conflict does **not** mean that something went wrong. +It simply means that some files require manual review. + +## Resolving the merge + +### Using the restore command + +The `restore` command can be used to update files on your working directory, i.e., the folder where your add-on repository was cloned. +The `--source` flag is used to determine where files to be restored can be found. + +### Keep your add-on documentation + +Your add-on documentation should not be replaced by the template. + +To keep your `.md` files from your add-on repository, ensuring they aren't replaced with files from the template, you can use the following command: + +```sh +git restore *.md --source=HEAD +``` + +### Remove the template documentation + +The `docs/` directory belongs to AddonTemplate itself. + +It is not intended to become part of your add-on repository. + +Remove it: + +``` +git rm -r docs +``` + +Or use the restore command: + +```sh +git restore docs --source=HEAD +``` + +### Resolve buildVars.py + +`buildVars.py` usually contains merge conflicts because it includes both: + +- information specific to your add-on; +- variables introduced by newer versions of AddonTemplate. + +Review the file carefully. + +In general: + +- keep your add-on metadata; +- preserve your version number; +- keep your custom settings; +- add any new variables introduced by the template. + +### Resolve pyproject.toml + +`pyproject.toml` is another file that commonly requires manual review. + +Keep your project-specific configuration while incorporating any new settings required by the updated template. + +### Other files + +For most remaining files, the version provided by AddonTemplate is generally the correct one. + +Typical examples include: + +- `.github/` +- `.gitignore` +- `manifest.ini.tpl` +- `manifest-translated.ini.tpl` +- `site_scons/` +- `sconstruct` + +Review any conflicts if necessary before completing the merge. + +## Completing the merge + +Once all conflicts have been resolved, check if the add-on can be built properly: + +```sh +uv sync # Update dependencies +uv run scons # Build the add-on +``` + + +If all is right, stage the modified files: + +```sh +git add . +``` + +Then create the merge commit: + +```sh +git commit +``` + +## Summary + +| File or directory | Recommended action | +|-------------------|--------------------| +| `README.md` | Keep the add-on version | +| `CHANGELOG.md` | Keep the add-on version | +| `docs/` | Remove | +| `buildVars.py` | Merge manually | +| `pyproject.toml` | Merge manually | +| Other template files | Usually accept the template version | + +## Troubleshooting + +### I don't understand a merge conflict + +Merge conflicts are expected when updating from a newer version of AddonTemplate. + +Most conflicts occur in `buildVars.py` and `pyproject.toml`. + +Review the conflicting sections carefully and combine the changes from both versions. + +### I want to cancel the update + +If you have not yet committed the merge, and you haven't passed the `--squash` flag to `git merge`, you can restore your repository to its previous state: + +```sh +git merge --abort +``` + +If you passed the `--squash` flag, `git merge --abort` won't work. +In this case, you can use the restore command: + +```sh +git restore . --staged # Discard changes added to the staging area (after using `git add .`) +``` + +```sh +git restore . --source=HEAD # Restores the working directory to the last commit made in your add-on repository +``` + +If you committed changes, you can use: + +```sh +git reset --hard {cleanBranch} +``` diff --git a/docs/unitTesting.md b/docs/unitTesting.md new file mode 100644 index 0000000..fece67f --- /dev/null +++ b/docs/unitTesting.md @@ -0,0 +1,17 @@ +# Unit Testing + +This template provides a built-in unit testing structure powered by Python's standard `unittest` framework. + +## Running Tests Locally + +To run the unit test suite locally using `uv`: + +``` bash +uv run python -m unittest discover -s tests -v +``` + +Or execute tests for a specific file: + +``` bash +uv run python -m unittest -v tests/unit/template/test_sanity.py +``` diff --git a/prek.toml b/prek.toml new file mode 100644 index 0000000..af9e227 --- /dev/null +++ b/prek.toml @@ -0,0 +1,139 @@ +# Configuration file for `prek`, a git hook framework written in Rust. +# See https://prek.j178.dev for more information. +#:schema https://www.schemastore.org/prek.json + +default_language_version = { python = "python3.13" } + +[[repos]] +repo = "meta" +hooks = [ + # ensures that exclude directives apply to any file in the repository. + { id = "check-useless-excludes" }, + # ensures that the configured hooks apply to at least one file in the repository. + { id = "check-hooks-apply" } +] + +# Built-in hooks reimplemented in Rust by prek. No repo clone or environment setup required. +[[repos]] +repo = "builtin" +hooks = [ + # Prevents commits to certain branches + { + id = "no-commit-to-branch", + args = [ + "--branch", + "main", + "--branch", + "master" + ] + }, + # Checks that large files have not been added. Default cut-off for "large" files is 500kb. + { id = "check-added-large-files" }, + # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) + { id = "check-case-conflict" }, + # Checks for artifacts from resolving merge conflicts. + { id = "check-merge-conflict" }, + # Removes trailing whitespace. + { + id = "trailing-whitespace", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Ensures all files end in 1 (and only 1) newline. + { + id = "end-of-file-fixer", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Removes the UTF-8 BOM from files that have it. + # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding + { + id = "fix-byte-order-marker", + types_or = [ + "python", + "c", + "c++", + "batch", + "markdown", + "toml", + "yaml", + "powershell" + ] + }, + # Validates TOML files. + { id = "check-toml" }, + # Validates YAML files. + { id = "check-yaml" }, + # Ensures that links to lines in files under version control point to a particular commit. + { id = "check-vcs-permalinks" } +] + +# These hooks use the upstream repo rather than prek's builtin equivalents: +# - check-ast and debug-statements have no builtin implementation. +# - The builtin check-illegal-windows-names only matches files that already have illegal names, +# so check-hooks-apply reports it as unused on a clean repo; the upstream hook checks all files. +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v5.0.0" +hooks = [ + # Checks python syntax + { id = "check-ast" }, + # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. + { id = "debug-statements" }, + # Avoids using reserved Windows filenames. + { id = "check-illegal-windows-names" } +] + +[[repos]] +repo = "https://github.com/asottile/add-trailing-comma" +rev = "v3.2.0" +hooks = [ + # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, + # if a trailing comma is added. + # This adds a trailing comma to args/iterable items in case it was missed. + { id = "add-trailing-comma" } +] + +[[repos]] +# Keep in sync with the pinned Ruff version in pyproject.toml. +repo = "https://github.com/astral-sh/ruff-pre-commit" +rev = "v0.14.5" +hooks = [ + { + id = "ruff", + name = "lint with ruff", + args = ["--fix"] + }, + { + id = "ruff-format", + name = "format with ruff" + } +] + +[[repos]] +repo = "local" +hooks = [ + { + id = "pyright", + name = "type check with pyright", + entry = "uv run pyright", + language = "system", + types = ["python"] + } +] diff --git a/pyproject.toml b/pyproject.toml index 97189ac..b0d1008 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,61 @@ +[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 +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 @@ -20,10 +78,14 @@ include = [ exclude = [ ".git", "__pycache__", + ".venv", + "buildVars.py", + "tests", ] [tool.ruff.format] indent-style = "tab" +line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -33,13 +95,16 @@ ignore = [ # indentation contains tabs "W191", ] +logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, +# sconscripts contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] +venvPath = ".venv" +venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -51,6 +116,10 @@ exclude = [ "sconstruct", ".git", "__pycache__", + ".venv", + "site_scons", + ".github/scripts", + "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. @@ -59,6 +128,7 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", + "../nvda/source", ] # General config @@ -120,7 +190,6 @@ reportPropertyTypeMismatch = true reportRedeclaration = true reportReturnType = true reportSelfClsParameterName = true -reportShadowedImports = true reportTypeCommentUsage = true reportTypedDictNotRequiredAccess = true reportUnboundVariable = true diff --git a/readme.md b/readme.md index 05e5f12..c8a4d39 100644 --- a/readme.md +++ b/readme.md @@ -36,19 +36,35 @@ In addition, this template includes configuration files for the following tools * Ruff (pyproject.toml/tool.ruff sections): a Python linter written in Rust. Sections starting with tool.ruff house configuration options for Ruff. * Configuration for VS Code. It requires NVDA's repo at the same level as the add-on folder containing your actual source files, with prepared source code (`scons source`). preparing the source code is a step in the instructions for building NVDA itself, see [The NVDA Repository](https://github.com/nvaccess/nvda) for details. * Place the .vscode in this repo within the addon folder, where your add-on source files (will) reside. The settings file within this folder assumes the NVDA repository is within the parent folder of this folder. If your addon folder is within the addonTemplate folder, then your NVDA repository folder needs to also be within the addonTemplate folder, or the source will not be found. - * Open the addon folder in VS Code. This should initialize VS Code with the correct settings and provide you with code completion and other VS Code features. + * Open the addon folder in VS Code. + This should initialize VS Code with the correct settings and provide you with code completion and other VS Code features. * Press `control+shift+m` after saving a file to search for problems. * Use arrow and tab keys for the autocompletion feature. * Press `control+shift+p` to open the commands palette and search for recommended extensions to install or check if they are installed. * Pyright (pyproject.toml/tool.pyright sections): a Python static type checker. Sections starting with tool.pyright house configuration options for Pyright. +## Automatic checks on GitHub + +### prek + +This template uses [prek](https://github.com/j178/prek) (a fast, drop-in alternative to pre-commit) to run linting, formatting, and type-checking hooks, configured in `prek.toml`. +`prek` is included as a development dependency, so you can run it through `uv`: + +* Run `uv run prek install` once to enable the git hook, so the checks run automatically on every commit. +* Run `uv run prek run --all-files` to check all files in the repository. + +The provided GitHub Actions workflow (`.github/workflows/build_addon.yml`) also runs these checks on every pull request, helping you maintain a consistent code style in your add-ons. + ## Requirements You need the following software to use this code for your NVDA add-on development and packaging: * a Python distribution (3.13 64-bit or later is recommended). Check the [Python Website](https://www.python.org) for Windows Installers. * Scons - [Website](https://www.scons.org/) - version 4.10.1 or later. You can install it via PIP. -* GNU Gettext tools, if you want to have localization support for your add-on - Recommended. Any Linux distro or cygwin have those installed. You can find windows builds [here](https://gnuwin32.sourceforge.net/downlinks/gettext.php). +* GNU Gettext tools (recommended). +Needed if you want to have localization support for your add-on. +Any Linux distro or cygwin have those installed. +You can find Windows builds [here](https://mlocati.github.io/articles/gettext-iconv-windows.html). * Markdown 3.8.2 or later, if you want to convert documentation files to HTML documents. You can install it via PIP. * Optional: additional tools such as linters and type checkers defined in pyproject.toml file. @@ -78,11 +94,19 @@ sconstruct ``` and file: ``` -.pre-commit-config.yaml +prek.toml +changelog.md +pyproject.toml +uv.lock ``` 4. Create an `addon` folder inside your new folder. You will put your code in the usual folders for NVDA extensions, under the `addon` folder. For instance: `globalPlugins`, `synthDrivers`, etc. -5. In the `buildVars.py` file, change variable `addon_info` with your add-on's information (name, summary, description, version, author, url, source url, license, and license URL). Also, be sure to carefully set the paths contained in the other variables in that file. If you need to use custom Markdown extensions, original add-on interface language is not English, or include custom braille translations tables, be sure to fil out markdown list, base language variable, and braille tables dictioanry, respectively. +5. In the `buildVars.py` file, change variable `addon_info` with your add-on's information (name, summary, description, version, author, url, source url, license, and license URL). Also, be sure to carefully set the paths contained in the other variables in that file. If you need to use custom Markdown extensions, original add-on interface language is not English, or include custom braille translations tables, be sure to fil out markdown list, base language variable, and braille tables dictionary, respectively. 6. Gettext translations must be placed into `addon\locale\/LC_MESSAGES\nvda.po`. +7. If you create releases with the GitHub workflow, pushing a tag, update the `changelog.md` file with the release description you want to be displayed in on your GitHub release page. +8. In the `[project]` section of `pyproject.toml`, update your project information. + +Alternatively, you can integrate this template in your add-on using Git. +For more details, read [integrating the add-on template using Git](docs/managementFromGit/updatingExistingAddons.md). #### Add-on manifest specification @@ -92,7 +116,7 @@ An add-on manifest generated manually or via `buildVars.py` must include the fol * Summary (string): name as shown on NVDA's Add-on store. * Description (string): a short detailed description about the add-on. * Version (string), ideally number.number with an optional third number, denoting major.minor.patch. -* Changelog (string): changes between previous and current add-on releases. +* Changelog (string): changes between previous and current add-on releases, visible in the Add-on Store. * Author (string and an email address): one or more add-on author contact information in the form "name ". * URL (string): a web address where the add-on information can be found such as add-on repository. * docFileName (string): name of the documentation file. @@ -108,7 +132,7 @@ In addition, the following information must be filled out (not used in the manif ##### Custom add-on information -In addition to the core manifest data, custom add-on information can be specified. +In addition to the core manifest data, custom add-on information can be specified. ###### Braille translation tables @@ -132,6 +156,19 @@ Information on custom symbol dictionaries must be specified in buildVars under ` Note: you must fill out this dictionary if at least one custom symbol dictionary is included in the add-on. If not, leave the dictionary empty. +###### Speech pronunciation dictionaries + +Information on custom speech (pronunciation) dictionaries must be specified in buildVars under `speechDictionaries` dictionary as follows: + +* Dictionary name (string key for a nested dictionary): each `symbolDictionaries` entry is a name for the included custom speech dictionary placed in `speechDicts` folder inside `addon` folder. +The file is named `.dic`. +This nested dictionary should specify: + * displayName (string): the name of the dictionary shown to users and is translatable. + * mandatory (True/False): Always enabled (True) or optional and visible in the GUI (False) + +Note: you must fill out this dictionary if at least one custom speech dictionary is included in the add-on. +If not, leave the dictionary empty. + ### To manage documentation files for your addon: 1. Copy the `readme.md` file for your add-on to the first created folder, where you copied `buildVars.py`. You can also copy `style.css` to improve the presentation of HTML documents. @@ -146,6 +183,15 @@ Note: you must fill out this dictionary if at least one custom symbol dictionary * channel: update channel (do not use this switch unless you know what you are doing). * dev: suitable for development builds, names the add-on according to current date (yyyymmdd) and sets update channel to "dev". + +### Translation workflow + +This template allows you to automate the synchronization of documentation and interface messages with Crowdin. + +For more details, please see the [translation guide for add-on authors](./docs/l10n/addonAuthors.md). + +A [translation guide for translators](./docs/l10n/addonTranslators.md) is also available. + ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: diff --git a/sconstruct b/sconstruct index 481a7ac..cdbad3f 100644 --- a/sconstruct +++ b/sconstruct @@ -61,6 +61,7 @@ env.Append( addon_info=buildVars.addon_info, brailleTables=buildVars.brailleTables, symbolDictionaries=buildVars.symbolDictionaries, + speechDictionaries=buildVars.speechDictionaries, ) if env["dev"]: diff --git a/site_scons/site_tools/NVDATool/__init__.py b/site_scons/site_tools/NVDATool/__init__.py index 6b4a37c..a71857d 100644 --- a/site_scons/site_tools/NVDATool/__init__.py +++ b/site_scons/site_tools/NVDATool/__init__.py @@ -13,6 +13,7 @@ - addon_info: .typing.AddonInfo - brailleTables: .typings.BrailleTables - symbolDictionaries: .typings.SymbolDictionaries +- speechDictionaries: .typings.SpeechDictionaries The following environment variables are required to build the HTML: @@ -29,24 +30,27 @@ from .docs import md2html - def generate(env: Environment): env.SetDefault(excludePatterns=tuple()) addonAction = env.Action( lambda target, source, env: createAddonBundleFromPath( - source[0].abspath, target[0].abspath, env["excludePatterns"] - ) and None, + source[0].abspath, + target[0].abspath, + env["excludePatterns"], + ) + and None, lambda target, source, env: f"Generating Addon {target[0]}", ) env["BUILDERS"]["NVDAAddon"] = Builder( action=addonAction, suffix=".nvda-addon", - src_suffix="/" + src_suffix="/", ) env.SetDefault(brailleTables={}) env.SetDefault(symbolDictionaries={}) + env.SetDefault(speechDictionaries={}) manifestAction = env.Action( lambda target, source, env: generateManifest( @@ -55,13 +59,15 @@ def generate(env: Environment): addon_info=env["addon_info"], brailleTables=env["brailleTables"], symbolDictionaries=env["symbolDictionaries"], - ) and None, + speechDictionaries=env["speechDictionaries"], + ) + and None, lambda target, source, env: f"Generating manifest {target[0]}", ) env["BUILDERS"]["NVDAManifest"] = Builder( action=manifestAction, suffix=".ini", - src_siffix=".ini.tpl" + src_siffix=".ini.tpl", ) translatedManifestAction = env.Action( @@ -72,17 +78,19 @@ def generate(env: Environment): addon_info=env["addon_info"], brailleTables=env["brailleTables"], symbolDictionaries=env["symbolDictionaries"], - ) and None, + speechDictionaries=env["speechDictionaries"], + ) + and None, lambda target, source, env: f"Generating translated manifest {target[0]}", ) env["BUILDERS"]["NVDATranslatedManifest"] = Builder( action=translatedManifestAction, suffix=".ini", - src_siffix=".ini.tpl" + src_siffix=".ini.tpl", ) - env.SetDefault(mdExtensions = {}) + env.SetDefault(mdExtensions={}) mdAction = env.Action( lambda target, source, env: md2html( @@ -91,7 +99,8 @@ def generate(env: Environment): moFile=env["moFile"].path if env["moFile"] else None, mdExtensions=env["mdExtensions"], addon_info=env["addon_info"], - ) and None, + ) + and None, lambda target, source, env: f"Generating {target[0]}", ) env["BUILDERS"]["md2html"] = env.Builder( diff --git a/site_scons/site_tools/NVDATool/addon.py b/site_scons/site_tools/NVDATool/addon.py index 42e8d0e..7d67516 100644 --- a/site_scons/site_tools/NVDATool/addon.py +++ b/site_scons/site_tools/NVDATool/addon.py @@ -3,7 +3,6 @@ from pathlib import Path - def matchesNoPatterns(path: Path, patterns: Iterable[str]) -> bool: """Checks if the path, the first argument, does not match any of the patterns passed as the second argument.""" return not any((path.match(pattern) for pattern in patterns)) diff --git a/site_scons/site_tools/NVDATool/docs.py b/site_scons/site_tools/NVDATool/docs.py index abccd41..e1f80ad 100644 --- a/site_scons/site_tools/NVDATool/docs.py +++ b/site_scons/site_tools/NVDATool/docs.py @@ -1,4 +1,3 @@ - import gettext from pathlib import Path @@ -7,15 +6,14 @@ from .typings import AddonInfo - def md2html( - source: str | Path, - dest: str | Path, - *, - moFile: str | Path|None, - mdExtensions: list[str], - addon_info: AddonInfo - ): + source: str | Path, + dest: str | Path, + *, + moFile: str | Path | None, + mdExtensions: list[str], + addon_info: AddonInfo, +): if isinstance(source, str): source = Path(source) if isinstance(dest, str): @@ -55,7 +53,7 @@ def md2html( "\n", htmlText, "\n", - ) + ), ) with dest.open("w", encoding="utf-8") as f: - f.write(docText) # type: ignore + f.write(docText) # type: ignore diff --git a/site_scons/site_tools/NVDATool/manifests.py b/site_scons/site_tools/NVDATool/manifests.py index 1e38348..7723b0b 100644 --- a/site_scons/site_tools/NVDATool/manifests.py +++ b/site_scons/site_tools/NVDATool/manifests.py @@ -1,20 +1,19 @@ - import codecs import gettext from functools import partial -from .typings import AddonInfo, BrailleTables, SymbolDictionaries +from .typings import AddonInfo, BrailleTables, SymbolDictionaries, SpeechDictionaries from .utils import format_nested_section - def generateManifest( - source: str, - dest: str, - addon_info: AddonInfo, - brailleTables: BrailleTables, - symbolDictionaries: SymbolDictionaries, - ): + source: str, + dest: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, + speechDictionaries: SpeechDictionaries, +): # Prepare the root manifest section with codecs.open(source, "r", "utf-8") as f: manifest_template = f.read() @@ -28,19 +27,24 @@ def generateManifest( if symbolDictionaries: manifest += format_nested_section("symbolDictionaries", symbolDictionaries) + # Custom speech pronunciation dictionaries + if speechDictionaries: + manifest += format_nested_section("speechDictionaries", speechDictionaries) + with codecs.open(dest, "w", "utf-8") as f: f.write(manifest) def generateTranslatedManifest( - source: str, - dest: str, - *, - mo: str, - addon_info: AddonInfo, - brailleTables: BrailleTables, - symbolDictionaries: SymbolDictionaries, - ): + source: str, + dest: str, + *, + mo: str, + addon_info: AddonInfo, + brailleTables: BrailleTables, + symbolDictionaries: SymbolDictionaries, + speechDictionaries: SpeechDictionaries, +): with open(mo, "rb") as f: _ = gettext.GNUTranslations(f).gettext vars: dict[str, str] = {} @@ -52,8 +56,8 @@ def generateTranslatedManifest( _format_section_only_with_displayName = partial( format_nested_section, - include_only_keys = ("displayName",), - _ = _, + include_only_keys=("displayName",), + _=_, ) # Add additional manifest sections such as custom braile tables @@ -65,5 +69,9 @@ def generateTranslatedManifest( if symbolDictionaries: manifest += _format_section_only_with_displayName("symbolDictionaries", symbolDictionaries) + # Custom speech pronunciation dictionaries + if speechDictionaries: + manifest += _format_section_only_with_displayName("speechDictionaries", speechDictionaries) + with codecs.open(dest, "w", "utf-8") as f: f.write(manifest) diff --git a/site_scons/site_tools/NVDATool/typings.py b/site_scons/site_tools/NVDATool/typings.py index 6b1b3f5..0375538 100644 --- a/site_scons/site_tools/NVDATool/typings.py +++ b/site_scons/site_tools/NVDATool/typings.py @@ -1,7 +1,6 @@ from typing import TypedDict, Protocol - class AddonInfo(TypedDict): addon_name: str addon_summary: str @@ -20,19 +19,25 @@ class AddonInfo(TypedDict): class BrailleTableAttributes(TypedDict): - displayName: str - contracted: bool - output: bool - input: bool + displayName: str + contracted: bool + output: bool + input: bool class SymbolDictionaryAttributes(TypedDict): - displayName: str - mandatory: bool + displayName: str + mandatory: bool + + +class SpeechDictionaryAttributes(TypedDict): + displayName: str + mandatory: bool BrailleTables = dict[str, BrailleTableAttributes] SymbolDictionaries = dict[str, SymbolDictionaryAttributes] +SpeechDictionaries = dict[str, SpeechDictionaryAttributes] class Strable(Protocol): diff --git a/site_scons/site_tools/NVDATool/utils.py b/site_scons/site_tools/NVDATool/utils.py index 0cc833c..c900841 100644 --- a/site_scons/site_tools/NVDATool/utils.py +++ b/site_scons/site_tools/NVDATool/utils.py @@ -3,7 +3,6 @@ from .typings import Strable - def _(arg: str) -> str: """ A function that passes the string to it without doing anything to it. diff --git a/site_scons/site_tools/gettexttool/__init__.py b/site_scons/site_tools/gettexttool/__init__.py index 900f8dc..ff4697e 100644 --- a/site_scons/site_tools/gettexttool/__init__.py +++ b/site_scons/site_tools/gettexttool/__init__.py @@ -44,12 +44,14 @@ def generate(env): ) env["BUILDERS"]["gettextPotFile"] = env.Builder( - action=Action("xgettext " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET"), suffix=".pot" + action=Action("xgettext " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET"), + suffix=".pot", ) env["BUILDERS"]["gettextMergePotFile"] = env.Builder( action=Action( - "xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, "Generating pot file $TARGET" + "xgettext " + "--omit-header --no-location " + XGETTEXT_COMMON_ARGS, + "Generating pot file $TARGET", ), suffix=".pot", ) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..b7ad83b --- /dev/null +++ b/tests/__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. + +"""Root test package for the add-on template repository.""" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..5d53c4a --- /dev/null +++ b/tests/unit/__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. + +"""Unit tests package.""" diff --git a/tests/unit/template/__init__.py b/tests/unit/template/__init__.py new file mode 100644 index 0000000..91c0863 --- /dev/null +++ b/tests/unit/template/__init__.py @@ -0,0 +1,9 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Unit test suite for the add-on template infrastructure. + +This package contains automated tests to verify the template configuration +and development tooling. +""" diff --git a/tests/unit/template/test_sanity.py b/tests/unit/template/test_sanity.py new file mode 100644 index 0000000..c47b777 --- /dev/null +++ b/tests/unit/template/test_sanity.py @@ -0,0 +1,24 @@ +# Copyright (C) 2026 NV Access Limited, Abdel +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +"""Sanity test suite for verifying unit test execution in the add-on template.""" + +import unittest + + +class TestTemplateSanity(unittest.TestCase): + """Minimal sanity test suite to verify the unit test runner setup.""" + + def test_runner_handles_passing_tests(self): + """Ensure that the test runner correctly detects passing tests.""" + self.assertTrue(True) + + @unittest.expectedFailure + def test_runner_handles_failing_tests(self): + """Ensure that the test runner correctly detects failing tests. + + Marked with @expectedFailure so CI remains green while demonstrating + failure detection. + """ + self.assertTrue(False) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..4484ce7 --- /dev/null +++ b/uv.lock @@ -0,0 +1,431 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } + +[package.dev-dependencies] +build = [ + { name = "markdown" }, + { name = "scons" }, +] +dev = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "prek" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] +l10n = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, +] +lint = [ + { name = "prek" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "ruff" }, + { name = "uv" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +build = [ + { name = "markdown", specifier = "==3.10" }, + { name = "scons", specifier = "==4.10.1" }, +] +dev = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { 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 = "ruff", specifier = "==0.14.5" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.11.15" }, +] +l10n = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, +] +lint = [ + { name = "prek", specifier = "==0.4.8" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "ruff", specifier = "==0.14.5" }, + { name = "uv", specifier = "==0.11.15" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/22/2a5beb4e21417c73233d9f65cf6f3e96e891b80d2f550a8f630ebc6b88c6/nodejs_wheel_binaries-24.16.0.tar.gz", hash = "sha256:c973cb69dc5fd16e6f6dc6e579e2c3d5534e2a1f57619dddf5ba070efa7dde37", size = 8056, upload-time = "2026-05-30T16:52:09.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/d1/68b43b53cd0fa83ae6fd406705023ca988d9e0ca41c724d82e66fbeb2ef6/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:d9f8f677dcf30e37ac244f07869726abe043f01eb0f45722b1df31cc2af7093c", size = 55666374, upload-time = "2026-05-30T16:51:39.588Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b2/40a989159599080da485de966c4c2d207e852ac7aa7864702626d96c8bf5/nodejs_wheel_binaries-24.16.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:3d0370fe7120ce9697a4f60d40480d2bd8808d9f30131458d5afc0040d4e5a51", size = 55838487, upload-time = "2026-05-30T16:51:43.383Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a7/cd42174fb5ff6faff7fa8d326a18914d8f232098ab5de055b57c16fa13ca/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:85dc92bbb79c851569c5925dcc2a4c915a034efab375f99e4e7e6bbe9cca8342", size = 60179540, upload-time = "2026-05-30T16:51:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/c8a1f9ae140aa28df8744d984d01d4b3af7cdd6555af12127f40ceb45a7d/nodejs_wheel_binaries-24.16.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:2f3036292811514ba847b3708492644764f88a833ac425c5f55007014308ddfd", size = 60716262, upload-time = "2026-05-30T16:51:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/7c35b3737f59e36d0249c265397b7bff570519b95301d6e16ea361e904ad/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:db8a8a76ebd2b28ecbfc9ad464baa3707241b9e050a30e2efdf6f60c0f886502", size = 62230592, upload-time = "2026-05-30T16:51:55Z" }, + { url = "https://files.pythonhosted.org/packages/04/96/d931255cf9d11a84d6b54d882dba7434646467d568ccf070ea3418638df3/nodejs_wheel_binaries-24.16.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f1a3d8f7b4491cbbd023ba3fc4e901fcca2d9fb80d57f24ba3890de8b1dbac03", size = 62841759, upload-time = "2026-05-30T16:51:59.407Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7b/8b7a3f41bc255411be30b6d7d288aab8ffd9ea2055db8555ced3548007b9/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_amd64.whl", hash = "sha256:bb136be9944f0662dcf1120f45193a6b75b13fac378971a95cc42c9f879a81aa", size = 42027734, upload-time = "2026-05-30T16:52:03.348Z" }, + { url = "https://files.pythonhosted.org/packages/17/66/1ed71f1f529b8ca727d42c7ceb9db0bef145ce4a13dfc86fb50aa44f3be6/nodejs_wheel_binaries-24.16.0-py2.py3-none-win_arm64.whl", hash = "sha256:8308940b5edd0a50dc5267ea36ba21c9f668e83fe0d9f293937174d3a7e31c36", size = 39714528, upload-time = "2026-05-30T16:52:06.421Z" }, +] + +[[package]] +name = "prek" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/46/e436a6eb9fdb4d3fd08d0ab7fdba19fe03a9e994ec810de57869b853bd8e/prek-0.4.8.tar.gz", hash = "sha256:d15d8bef72ab7b02c7dc01458ac9e05b3131534492b5ce9bb11c4f6f636fa868", size = 494570, upload-time = "2026-07-04T12:05:10.941Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/78/b4149c8913ced2e42debb49e261c4788a1ce431e84226921c2e1a7ea8545/prek-0.4.8-py3-none-linux_armv6l.whl", hash = "sha256:1f8f8cdc65836b571824c965daebb81b449f7e4a43894c58621f5708d5a185ed", size = 5668955, upload-time = "2026-07-04T12:04:41.588Z" }, + { url = "https://files.pythonhosted.org/packages/76/5f/7f54a0087b6b2f1751aeb41266d9c15e66fd0055492814798ab818cd0414/prek-0.4.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bce1798e96d9e3a6e6abf435da7107e81452f69edb3ca7c6f90a457355ea46e2", size = 6030947, upload-time = "2026-07-04T12:04:43.8Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d6/f2829fc3902920c36b764a386fa303e71a8219dac25cb3827c575e84199a/prek-0.4.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ab3a52db17254d701c3cebb7eea58c8230aa7c1959aacfd5b5f25de18edb15d1", size = 5572593, upload-time = "2026-07-04T12:04:45.763Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/c5589955bcd5e3e33b67d8bc3110818cecac82a38fd6bc8b5dfdc5de421c/prek-0.4.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:b3fcfd620523bbc3f51a21d7cd63449f659b9e2cf3582de12dd5949e23227b8f", size = 5847150, upload-time = "2026-07-04T12:04:47.419Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9d/1f2dc91bdb79d2c4714b27eac9477a51490fba5b4731330dbbebc76bd345/prek-0.4.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42e65bc8425e9d7f1691a13ca1da2e07807d1ba76c35740833354b945131689e", size = 5573738, upload-time = "2026-07-04T12:04:49.125Z" }, + { url = "https://files.pythonhosted.org/packages/81/29/69a7b58e16ecbc5f3989bf4b028018d11a82dcdd320b93d6588d72f32aa7/prek-0.4.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f578492a8e0c9bc6b4bf6dfbba8716f647d4cd0769bf10ad6cf336e3096fd392", size = 5981054, upload-time = "2026-07-04T12:04:50.842Z" }, + { url = "https://files.pythonhosted.org/packages/63/cc/9b9850a60c22ed18c7755ebd2d72c6eefb37fac58149d09f6adc4691c2cf/prek-0.4.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4335f9d5beb123a3884a7fe34f57c9f0828f4fbb7666beab4298833459b104f", size = 6751350, upload-time = "2026-07-04T12:04:52.529Z" }, + { url = "https://files.pythonhosted.org/packages/01/e5/c425aa7272b430630119e6757def3a2007555ba8cbeb2630e0448e7a8b7f/prek-0.4.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18a8747df9c602e052881d3efb14dd7f7d62a59bd7277ae5171c9e7661d59d84", size = 6243881, upload-time = "2026-07-04T12:04:54.703Z" }, + { url = "https://files.pythonhosted.org/packages/1c/da/accd3ad07fd2891d3c2777eb42435439fdf11982c51d60f087c0b6b6e102/prek-0.4.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:4db639db481d5f854eff9b3d2108889e613b8c15868bcf6bdd777c7cee577436", size = 5848846, upload-time = "2026-07-04T12:04:56.402Z" }, + { url = "https://files.pythonhosted.org/packages/15/00/3477704635249f21f5f98ce444cd7690c2aa9dc8d146a045db88ef2cd8c5/prek-0.4.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c3890a6f92316d2cf44eb50584e8d2b23a596dd70487022e61186a71a2ac0900", size = 5713942, upload-time = "2026-07-04T12:04:58.311Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e6/3ca4fabaebeadc976d9a92d1d9130674265355ea3b728418bad61583b097/prek-0.4.8-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:fc7e15c24c591a37c6ffce5b25a021b16c299ac2649f183d812b67d665cd6551", size = 5554725, upload-time = "2026-07-04T12:04:59.96Z" }, + { url = "https://files.pythonhosted.org/packages/a5/46/2ab6aaaeff0cedb8955b2e4032071c8712382bdd423bb849718c3720180d/prek-0.4.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:36fe721704ff0c7624c1167639e23a5fe658bfd38c314f487219c9afd1eeb733", size = 5838595, upload-time = "2026-07-04T12:05:01.861Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8b/91398f2b6cd1629d5d8ca8c85b08eca500814a374313b0193f4aaf6ab6c4/prek-0.4.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:162e544abc394a8124f3a4ad68efee116bad09440e679dbd1675177335c2a432", size = 6357222, upload-time = "2026-07-04T12:05:03.845Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2a/ce5cbfaad36866134a21754640a05ecdba641fcd7ad15aa74cf3443f34f6/prek-0.4.8-py3-none-win32.whl", hash = "sha256:2602e46c8c5da7dfa69f60fcf88c2b57132ac623f49fb08bfb3094298c5f07e3", size = 5354388, upload-time = "2026-07-04T12:05:05.587Z" }, + { url = "https://files.pythonhosted.org/packages/df/03/3bc908bc5f7e430315553e47dfa055f19923a3888f9afe4da19f244b5cbf/prek-0.4.8-py3-none-win_amd64.whl", hash = "sha256:7cb22da60bee41b89c4978c0bea7126a3c0ccc003dae6748cf29b53947815edc", size = 5748221, upload-time = "2026-07-04T12:05:07.559Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a7/4295e6d5f5028171dfeb115ad38ab76bf3fe0c8df91b70d73c79aa760a94/prek-0.4.8-py3-none-win_arm64.whl", hash = "sha256:da70057f577b15d4bd121bf9dd29ee205fd4b4d75a0cafba062e84d7e8b4378b", size = 5574425, upload-time = "2026-07-04T12:05:09.595Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +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" } +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" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +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 = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uv" +version = "0.11.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/609d5d01ba21dc8f0974610ca7802fbb2c946a0c38665cfe5c5aeddbefb5/uv-0.11.15.tar.gz", hash = "sha256:755f959ec6a2fd8ccb6ee76ad90ab759d2eb1f4797444078645dd1ee4bca92d6", size = 4159545, upload-time = "2026-05-18T19:57:48.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/7c/dcc230c5911884d8848145dabcac8fb95a5ed6f9fe1c57fae8242618f28a/uv-0.11.15-py3-none-linux_armv6l.whl", hash = "sha256:83b04ab49514a0a761ffedb36a748ee81f87746671e72088e5f32c9585e5f1a9", size = 23110183, upload-time = "2026-05-18T19:57:23.051Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f3/efd4e044b60eb9c3c12ee386be098d56c335538ccec7caa49349cfba9344/uv-0.11.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6cae61f737be075b90be9e3f07d961072aed7019f4c9b8ed5c5d41c4d6cade3", size = 22637941, upload-time = "2026-05-18T19:57:26.752Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b8/48627f895a1569e576822e0a8416aa4797eb4a4551de21a4ad97b9b5819d/uv-0.11.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9accae33619a9166e5c48531deb455d672cfb89f9357a00975e669c76b0bd49f", size = 21258803, upload-time = "2026-05-18T19:57:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/af/50/4bc8a148274feabee2d9c9f1fa15009e10c0228dfe57981ee3ea2ef1d481/uv-0.11.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:c0cf52cd6d50bb9e05e2d968f45f80761107e4cbc8d4a26d9758f9d8274aaec1", size = 23066178, upload-time = "2026-05-18T19:57:33.058Z" }, + { url = "https://files.pythonhosted.org/packages/a9/56/139fc3bec9a8b0a25bfe2196123adb9f16124da437bf4fbcf0d21cfcafb2/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:49dc6ed70bff00937384f96cdc4b1a4742d18e5504ec2c4a1214dba2dee5687a", size = 22705332, upload-time = "2026-05-18T19:57:36.714Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b0/b18b3dd204f8c213236a1ebd148e009861637129a8cce34df0e9aa22ed40/uv-0.11.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:adb9a89352539fdd8f7cd5f9966cf9f94fc5b98e0ccdf5003a04123dc6423bec", size = 22707534, upload-time = "2026-05-18T19:58:04.117Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/3ca09f95572df99d361b49c96b1297149e96e120d8d1ecf074095a4b6da4/uv-0.11.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40ff67e3f8e8a7533781a2e892a534975a93acb83ea35460e64e7b2bf2111774", size = 24096607, upload-time = "2026-05-18T19:58:11.625Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/3bdee21a296bbf5336a526e3613d0e7d4538dacc39c62d7fcba55d15f6b0/uv-0.11.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c6463a299ed7e6b5a800ed6f108af8e1588352629424133ddef7572b0e1e1118", size = 25082562, upload-time = "2026-05-18T19:57:40.69Z" }, + { url = "https://files.pythonhosted.org/packages/cd/73/f371f3689ffe741066468d001d85f739fc4b5574de83b639ef19b5e8a7f4/uv-0.11.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68c1e62d4b78578b90b833553286b65d6a7e327537716441068583ba652ec4f5", size = 24253391, upload-time = "2026-05-18T19:57:18.47Z" }, + { url = "https://files.pythonhosted.org/packages/d3/16/fe392d618af6b00c064b3e718d585dcf791546a77c5123a5bec07ce53a0a/uv-0.11.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98edf1bdaf82447014852051d93e3ee95012509c567bf057fd117e6bdbd9a807", size = 24415871, upload-time = "2026-05-18T19:58:19.651Z" }, + { url = "https://files.pythonhosted.org/packages/6e/24/2e92a052fb6334fcd746d1c7cb57847c204b118c84f5da53c0f9e129f7b7/uv-0.11.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:be8f76d25bcf4c92bb384240ac1bf9aa7f51063d0bdeca4c9cf0ec3ed8b145e0", size = 23159007, upload-time = "2026-05-18T19:57:10.653Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/6923d0658d164bb2c435ed1868aa2d49b3074594679917a001ff92dc95bb/uv-0.11.15-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:f9f4fbbf4fe485522054f3c7496c6e8e932d6436e4200ff3daf718db0b7c7bd5", size = 23769385, upload-time = "2026-05-18T19:58:15.856Z" }, + { url = "https://files.pythonhosted.org/packages/a4/99/7e34cd949e57360814e8064cc9fb7104df445d0f6a663504e5f7473480aa/uv-0.11.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0ed920e896b2fd13a35031707e307e42fbb2681458b967440a17272d86d49137", size = 23860973, upload-time = "2026-05-18T19:57:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/28/98/8fe1f5f9d816e94569a0298dd8e0936801097625fa1952162951f0d628b6/uv-0.11.15-py3-none-musllinux_1_1_i686.whl", hash = "sha256:41d907611f3e6a13262807fd7f0a17849f76285ca80f536f6b3943732bdc6656", size = 23431392, upload-time = "2026-05-18T19:57:59.814Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6b/76a1ce2fa860026913a5941700cdc7d715fce9c3277a3fa3489cf2523ca0/uv-0.11.15-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e3b68f8bf1a4568710f77e5bda9182ce7682811d89a8e7468c22460e032b234d", size = 24519478, upload-time = "2026-05-18T19:57:51.165Z" }, + { url = "https://files.pythonhosted.org/packages/43/60/1d58e8a05718cb50494763115710b73846cacb651fd735d285233fd72c59/uv-0.11.15-py3-none-win32.whl", hash = "sha256:8e2da3076761086a5b76869c3f38ef0509c836046ef41ddd19485dfd7271dca9", size = 22020178, upload-time = "2026-05-18T19:58:07.64Z" }, + { url = "https://files.pythonhosted.org/packages/55/53/40fcefcb348af660488597ed3c01363df7344e60611f8883750dc596f5c6/uv-0.11.15-py3-none-win_amd64.whl", hash = "sha256:cc3915ab291a1ecaf31de05f5d8bd70d09c66fe9911a53f70d9efa62ff0dbd8a", size = 24668779, upload-time = "2026-05-18T19:57:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7d/fa3a9960c95af9bbe2a629048760d0b9b4fead8ccd4f2235af747ec7cdf0/uv-0.11.15-py3-none-win_arm64.whl", hash = "sha256:4f39426a13dee24897aed60c4b98058c66f18bd983885ac5f4a54a04b24fbddf", size = 23198178, upload-time = "2026-05-18T19:57:14.68Z" }, +] + +[[package]] +name = "wrapt" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/9f/06263fcd8ad6c405f05a3905fd7a84dd3176eb5ad46e44bccc0cd16348bb/wrapt-2.2.1.tar.gz", hash = "sha256:6744f504375775d7609c82c8d3d94af1c9a6f05586984536905908ba905277b9", size = 127620, upload-time = "2026-05-22T14:49:43.056Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/d1/a1b08f8f4fac8cbb156fa51cf64ee2c7f7f74f9875ba3cf70b3c58368694/wrapt-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d2beb1c7cab10603aecdc42f8edd6ff013f9a32e4543474e38e6b77ce9975aeb", size = 80831, upload-time = "2026-05-22T14:48:15.598Z" }, + { url = "https://files.pythonhosted.org/packages/54/ce/57890814991446a845e09b3445ce8b694f27eb0577004f2c2a36a9772ed4/wrapt-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e0cb7e4dd71f4c32e5e84843cd3c4cd65dda034314004bbe1d7f99af2426ab80", size = 81375, upload-time = "2026-05-22T14:48:17.071Z" }, + { url = "https://files.pythonhosted.org/packages/38/65/08d7a6c76ac4493bdb668205ee9c1de1bd5daca61717c3e9aa49b4c01499/wrapt-2.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95821352042722cd9f1108874579a47989d0a7e12a37d87d2fc4af20fd99ab8a", size = 167417, upload-time = "2026-05-22T14:48:18.303Z" }, + { url = "https://files.pythonhosted.org/packages/62/ce/f1ccbee7a1bfe5cdc6b3da6bab4b45713d628b9294da32a39f563d648140/wrapt-2.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abd621552ede77c4c69be7fac44ba911225b0c812b6ba604e5964cf98085b474", size = 166948, upload-time = "2026-05-22T14:48:19.768Z" }, + { url = "https://files.pythonhosted.org/packages/86/2a/f85d48d1cd4869aee6704028d257d740a47c1c467b457ce396b4b5b55d07/wrapt-2.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3677c7146ce694874941ba82b57092cc4875445aadf29d72807351023105143", size = 158148, upload-time = "2026-05-22T14:48:21.96Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5c/93939ad11d4a12358ab1aab219a2ef5efa5612e0db6b9fc65af8af1a891b/wrapt-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9a5934eaea872e17936b5f45501eba5ab0bce9a74122e172b663d7c28c459c4a", size = 165905, upload-time = "2026-05-22T14:48:23.373Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/b8c2aa89862ff58605934d7abf4b70e6a5a1c33df96656f49035ccdf1c8a/wrapt-2.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f5b9daf6b629fce418e0cc3dd0436eac045188fa35deadb7a7f3941d5b8203f9", size = 156712, upload-time = "2026-05-22T14:48:24.767Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/bf00a7b02239c12bb02ddcc3c0b971bfcc36e578c5a44f1ccfef5b458545/wrapt-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f53ac9f3ef573326d009ed809beff4efcac6451931c2b8132586da4b9e53ff31", size = 166560, upload-time = "2026-05-22T14:48:26.83Z" }, + { url = "https://files.pythonhosted.org/packages/fe/93/6390ca9c5b787683cef588d04f57c8d41b9a2323b5597a65f18638c90ef2/wrapt-2.2.1-cp313-cp313-win32.whl", hash = "sha256:1ffa9cfd4bdb581539951b14ae661ff20ed0c3599b3e911a131ee0ec5ac11337", size = 77817, upload-time = "2026-05-22T14:48:28.221Z" }, + { url = "https://files.pythonhosted.org/packages/97/73/ce10f0e71c0cfaa1a65faadb8efd4852028b3bb9ba28932b8889df769d38/wrapt-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:368eac1e20fd0bb03dd3cc42bf9887154c3861b60989389ccb5fac032617d215", size = 80736, upload-time = "2026-05-22T14:48:30.139Z" }, + { url = "https://files.pythonhosted.org/packages/c7/4c/89f4a6818fafbbd840330e4fa3873073e1bfc166133a64cac7f8fde7a5e3/wrapt-2.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:c754dafdf5aaf0b401b644a90a30046929a0dd1a536e0ff0ec959a59155d9c7f", size = 79099, upload-time = "2026-05-22T14:48:31.405Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f2/9a8741c46f8c208ac0a45b25ba170bcb4fb72a2781d5fb97dbd7b6be73cb/wrapt-2.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ed928d0fda15fc0adc8d13305c8b3c0f2fba5b0669950c9e6d019d9162a3b3e8", size = 82802, upload-time = "2026-05-22T14:48:33.307Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0d/e9c855716a3705eef1416456bdf062b60620726fdc59428ff670fc3c60dc/wrapt-2.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fafb4e739e43544d12cb4abd1605fd4683b6ca6a9ad682b7fd8f4d21973eafa8", size = 83329, upload-time = "2026-05-22T14:48:34.593Z" }, + { url = "https://files.pythonhosted.org/packages/3b/d6/a88f1c13112b7831adac75cea65d8310e0d696d570c8961844c90a57b865/wrapt-2.2.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:74d6a0c31472fe5d814917266b9f46495d7c61ed890af08b468acea92fb89a8d", size = 202937, upload-time = "2026-05-22T14:48:35.859Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/e29d54aef06a4d898a5b8a25589a0b3769bde454f922fad8f6f89fbfb650/wrapt-2.2.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab5be648d5a0b86b7438864f8df3c705a65cef35a2fd3e5561e3e203167e0f27", size = 209997, upload-time = "2026-05-22T14:48:38.153Z" }, + { url = "https://files.pythonhosted.org/packages/2a/91/e4454263516cf0e12640912fbca9a83654e424f0a6ddb79f5cd7ce14bf33/wrapt-2.2.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d8f204c8e3a8bf9ece17e0a83d137fd807440977f8a5e762d59306795011440", size = 194856, upload-time = "2026-05-22T14:48:39.69Z" }, + { url = "https://files.pythonhosted.org/packages/de/d0/fe0ee202286afdf4a7f77dd29f195703145764d572aec209c5086e57d924/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d047f6498c973874ba08ac3f97c69a2c4b2211c8de6f4c205f75cb1c9522596e", size = 205654, upload-time = "2026-05-22T14:48:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/23/b6/87d860dfc6460c246af70b1fd5c8b76df77571b42a493459423ded94fd7d/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:7a4fdb9326aab4a5a477a1640e5ad786a8495901009d7e7b038371edd23a9d2b", size = 192206, upload-time = "2026-05-22T14:48:44.858Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/3eea8cde077d985f239a38c0257087b8064fd9ee9b1a99e282d2c86da4ef/wrapt-2.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c8cc5094b08abeae52da9c73c8a32003623be691a5193df2f4e3eac3d557c394", size = 198428, upload-time = "2026-05-22T14:48:46.319Z" }, + { url = "https://files.pythonhosted.org/packages/18/dc/b927ee9c7fc67adc3a5658f246a0d275425eb840ba36e7b702e70f18bde8/wrapt-2.2.1-cp313-cp313t-win32.whl", hash = "sha256:9907a4402ab6db12b7077a0ea5d7a4d028ecb22c8eee2b53527080d347cd1562", size = 79448, upload-time = "2026-05-22T14:48:47.901Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b3/fd30b473fe498c70e6b9a5f328b8d3fbaf1b8c3c481465f59724bba8eb70/wrapt-2.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:5590d63f5243251641cf543009b4c9314a79d0598fdb8a8e4cfc918494536c53", size = 83021, upload-time = "2026-05-22T14:48:49.201Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f3/96c39153a8737a6e9aa85adef254ac4195bea3f2d24efc60472ccc3c9e2e/wrapt-2.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:c318a64b53d97b841d7b5e637517e50a27be64bc695128422953d4b21710954e", size = 80295, upload-time = "2026-05-22T14:48:50.479Z" }, + { url = "https://files.pythonhosted.org/packages/53/46/29ac9daf11a86c22a8c38cd9236c62928ccae83f7ceb06bd3b0467cf9d05/wrapt-2.2.1-py3-none-any.whl", hash = "sha256:3aafea2975caef8ca49400640dde02cc7426e798f24870ed01f490bc3cffd32f", size = 61000, upload-time = "2026-05-22T14:49:41.593Z" }, +]