diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 346a9ef..9d03dc6 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -100,7 +100,7 @@ def getScoreFromApi(fileNameToSearch: str, langId: str) -> float: # Also handles underscore to dash conversion for Crowdin compatibility if langApi.lower().startswith(langId.lower().replace("_", "-")): progress = float(item["data"]["translationProgress"]) - return progress / 100 + return progress # Check pagination total. total = resp["pagination"]["totalCount"] @@ -139,9 +139,6 @@ def main(): # Default to poScore for .po and other localization files. print(f"poScore={score}") - # Exit with success (0) if there is at least 50% translated content. - sys.exit(0 if score > 0.5 else 1) - if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 9b99315..8e7bb92 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -23,15 +23,17 @@ if (Test-Path $mdFile) { try { Copy-Item "$addonId.xliff" $tempXliff -Force Write-Host "DEBUG: Updating XLIFF source based on readme.md..." - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile - } finally { + ./l10nUtil.exe md2xliff $mdFile $xliffFile -o $tempXliff + } + finally { if (Test-Path $tempXliff) { Remove-Item $tempXliff -Force } } - } else { + } + else { Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." - uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile + ./l10nUtil.exe md2xliff $mdFile $xliffFile } } @@ -49,8 +51,10 @@ if (Test-Path $potFile) { 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" } @@ -69,127 +73,155 @@ New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $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 } + if ($langCode -eq "en") { + continue + } # --- Identify codes + $crowdinLang = $null - # Use the ."variable" syntax to correctly read the PSCustomObject from JSON if ($languageMappings.PSObject.Properties.Name -contains $langCode) { $crowdinLang = $languageMappings."$langCode" } - # Fallback: If no mapping is found, replace underscores with dashes for Crowdin compatibility if (-not $crowdinLang) { $crowdinLang = $langCode.Replace('_', '-') } - # The $langCode (folder name from Crowdin) represents the local repository language code. - # It matches the NVDA directory structure, so no extra mapping is needed. Write-Host "--- Processing Language: $langCode (Crowdin: $crowdinLang) ---" -ForegroundColor Cyan # Paths - $remoteMd = Join-Path $dir.FullName "$addonId.md" + $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: Checking Remote PO progress for $crowdinLang..." - uv run python .github/scripts/checkTranslation.py "$addonId.po" $crowdinLang - if ($LASTEXITCODE -eq 0) { - Write-Host "SUCCESS: Remote PO is valid. Importing to $localPoPath" + + 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 progress is below threshold." + } + 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 (MD & XLIFF) --- - $scoreMd = 0.0 - $scoreXliff = 0.0 + # --- 3.2 DOCUMENTATION PROCESSING (XLIFF ONLY) --- - if (Test-Path $remoteMd) { - Write-Host "DEBUG: Evaluating Remote Markdown score..." - $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $crowdinLang - $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] - } else { - Write-Host "DEBUG: No remote Markdown file found for this language." - } + $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 { + + $scoreXliff = [double]( + ($res | Select-String "xliffScore=").ToString().Split("=")[1] + ) + } + else { Write-Host "DEBUG: No remote XLIFF file found for this language." } - Write-Host "DEBUG: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + Write-Host "DEBUG: XLIFF Score -> $scoreXliff" - $threshold = 0.5 + $threshold = $env:MIN_PERCENTAGE_TRANSLATED $docImported = $false - if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { - if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } - - if ($scoreXliff -ge $scoreMd) { - Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($langCode)..." - ./l10nUtil.exe xliff2md $remoteXliff $localMd - $docImported = $true - } else { - Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($langCode)..." - Move-Item $remoteMd $localMd -Force - $docImported = $true + if ($scoreXliff -ge $threshold) { + + if (!(Test-Path $localMdDir)) { + New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } - } else { - Write-Host "WARNING: Both remote MD and XLIFF scores are below threshold ($threshold)." + + Write-Host "SUCCESS: Importing documentation from XLIFF ($langCode)..." + + ./l10nUtil.exe xliff2md $remoteXliff $localMd + + $docImported = $true } + else { - if (-not $docImported -and (Test-Path $localMd)) { - Write-Host "ACTION: Documentation quality too low. Uploading local MD to Crowdin ($crowdinLang) as fallback." - ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c $env:L10N_UTIL_CONFIG + 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 { +} +else { + Write-Host "DEBUG: No changes in translations to commit." } # Push all generated commits after successful Crowdin synchronization + $pushOutput = git push 2>&1 -# Get the full repository name in "owner/repository" format (e.g., hkatic/clock) $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/langCodes.py b/.github/scripts/langCodes.py deleted file mode 100644 index 044e5b5..0000000 --- a/.github/scripts/langCodes.py +++ /dev/null @@ -1,60 +0,0 @@ -import sys - -# Mapping between Crowdin language IDs (keys) and standard NVDA directory names (values). -# This dictionary acts as the symmetrical counterpart to 'languageMappings.json' implemented by @nvdaes. -# It ensures that translations exported from Crowdin are stored in the correct -# local paths (e.g., 'es-ES' from Crowdin goes into the 'es' folder). -CROWDIN_TO_NVDA = { - # Arabic variants - "ar-SA": "ar_SA", - - # Spanish variants - "es-ES": "es", - "es-CO": "es_CO", - - # Portuguese variants - "pt-BR": "pt_BR", - "pt-PT": "pt_PT", - - # Chinese variants - "zh-CN": "zh_CN", - "zh-HK": "zh_HK", - "zh-TW": "zh_TW", - - # Other specific mappings from the NVDA ecosystem - "af": "af_ZA", - "de-CH": "de_CH", - "nb": "nb_NO", - "nn-NO": "nn_NO", - "sr-CS": "sr" -} - -def get_nvda_code(crowdin_code): - """ - Returns the appropriate local directory name for a given Crowdin language ID. - - Args: - crowdin_code (str): The language identifier from Crowdin (e.g., 'pt-BR', 'fr'). - - Returns: - str: The corresponding NVDA locale folder name (e.g., 'pt_BR', 'fr'). - """ - # 1. Direct check in our verified map (Priority) - if crowdin_code in CROWDIN_TO_NVDA: - return CROWDIN_TO_NVDA[crowdin_code] - - # 2. Automated conversion for regional variants: Crowdin "xx-YY" -> NVDA "xx_YY" - # This handles regional codes not explicitly defined in the map. - if "-" in crowdin_code: - return crowdin_code.replace("-", "_") - - # 3. Default: Return as is. - # This covers base languages that don't use regional folders in NVDA - # (e.g., 'fr', 'tr', 'bg', 'fi', 'fa'). - return crowdin_code - -if __name__ == "__main__": - # Ensure a language code was provided as a command-line argument - if len(sys.argv) > 1: - # Standardize input and output the mapped code for PowerShell to capture - print(get_nvda_code(sys.argv[1])) \ No newline at end of file diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py deleted file mode 100644 index 165e940..0000000 --- a/.github/scripts/markdownTranslate.py +++ /dev/null @@ -1,881 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2026 NV Access Limited. -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -from collections.abc import Generator -from collections.abc import Iterable -import tempfile -import os -import contextlib -import lxml.etree -import argparse -import uuid -import re -from itertools import zip_longest -from xml.sax.saxutils import escape as xmlEscape -import difflib -from dataclasses import dataclass -import subprocess - - -def getGithubRepoURL() -> str | None: - """ - Get the GitHub repository URL from git remote origin. - return: The raw GitHub URL for the repository, or None if it cannot be determined. - """ - result = subprocess.run( - ["git", "remote", "get-url", "origin"], - capture_output=True, - text=True, - check=True, - ) - remote_url = result.stdout.strip() - # Convert SSH or HTTPS URL to raw GitHub URL format - if match := re.match(r"git@github\.com:(.+?)(?:\.git)?$", remote_url): - repo_path = match.group(1) - elif match := re.match(r"https://github\.com/(.+?)(?:\.git)?$", remote_url): - repo_path = match.group(1) - else: - raise ValueError(f"Cannot parse GitHub URL from git remote: {remote_url}") - return f"https://raw.githubusercontent.com/{repo_path}" - - -re_kcTitle = re.compile(r"^()$") -re_kcSettingsSection = re.compile(r"^()$") -# Comments that span a single line in their entirety -re_comment = re.compile(r"^$") -re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") -re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") -re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") -re_hiddenHeaderRow = re.compile(r"^\|\s*\.\s*\{\.hideHeaderRow\}\s*(\|\s*\.\s*)*\|$") -re_postTableHeaderLine = re.compile(r"^(\|\s*-+\s*)+\|$") -re_tableRow = re.compile(r"^(\|)(.+)(\|)$") -re_translationID = re.compile(r"^(.*)\$\(ID:([0-9a-f-]+)\)(.*)$") -re_inlineMarkdownLintComment = re.compile(r"^(.*?)(?:\s*)(\s*)$") - - -def prettyPathString(path: str) -> str: - cwd = os.getcwd() - if os.path.normcase(os.path.splitdrive(path)[0]) != os.path.normcase( - os.path.splitdrive(cwd)[0], - ): - return path - return os.path.relpath(path, cwd) - - -@contextlib.contextmanager -def createAndDeleteTempFilePath_contextManager( - dir: str | None = None, - prefix: str | None = None, - suffix: str | None = None, -) -> Generator[str, None, None]: - """A context manager that creates a temporary file and deletes it when the context is exited""" - with tempfile.NamedTemporaryFile( - dir=dir, - prefix=prefix, - suffix=suffix, - delete=False, - ) as tempFile: - tempFilePath = tempFile.name - tempFile.close() - yield tempFilePath - os.remove(tempFilePath) - - -def getLastCommitID(filePath: str) -> str: - # Run the git log command to get the last commit ID for the given file - result = subprocess.run( - ["git", "log", "-n", "1", "--pretty=format:%H", "--", filePath], - capture_output=True, - text=True, - check=True, - ) - commitID = result.stdout.strip() - if not re.match(r"[0-9a-f]{40}", commitID): - raise ValueError(f"Invalid commit ID: '{commitID}' for file '{filePath}'") - return commitID - - -def getGitDir() -> str: - # Run the git rev-parse command to get the root of the git directory - result = subprocess.run( - ["git", "rev-parse", "--show-toplevel"], - capture_output=True, - text=True, - check=True, - ) - gitDir = result.stdout.strip() - if not os.path.isdir(gitDir): - raise ValueError(f"Invalid git directory: '{gitDir}'") - return gitDir - - -def getRawGithubURLForPath(filePath: str) -> str: - gitDirPath = getGitDir() - commitID = getLastCommitID(filePath) - relativePath = os.path.relpath(os.path.abspath(filePath), gitDirPath) - relativePath = relativePath.replace("\\", "/") - rawGithubRepoUrl = getGithubRepoURL() - return f"{rawGithubRepoUrl}/{commitID}/{relativePath}" - - -def preprocessMarkdownLines(mdLines: Iterable[str]) -> Iterable[str]: - """ - Preprocess markdown lines such as removing inline markdown lint comments.\ - :param mdLines: The markdown lines to preprocess - :returns: The preprocessed markdown lines - """ - for mdLine in mdLines: - # #18982: Remove markdown lint comments completely - not needed for intermediate markdown or final html. - mdLine = re_inlineMarkdownLintComment.sub(r"\1\2", mdLine) - yield mdLine - - -def skeletonizeLine(mdLine: str) -> str | None: - prefix = "" - suffix = "" - if ( - mdLine.isspace() - or mdLine.strip() == "[TOC]" - or re_hiddenHeaderRow.match(mdLine) - or re_postTableHeaderLine.match(mdLine) - ): - return None - elif m := re_heading.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_bullet.match(mdLine): - prefix, content = m.groups() - elif m := re_number.match(mdLine): - prefix, content = m.groups() - elif m := re_tableRow.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcTitle.match(mdLine): - prefix, content, suffix = m.groups() - elif m := re_kcSettingsSection.match(mdLine): - prefix, content, suffix = m.groups() - elif re_comment.match(mdLine): - return None - ID = str(uuid.uuid4()) - return f"{prefix}$(ID:{ID}){suffix}\n" - - -@dataclass -class Result_generateSkeleton: - numTotalLines: int = 0 - numTranslationPlaceholders: int = 0 - - -def generateSkeleton(mdPath: str, outputPath: str) -> Result_generateSkeleton: - print( - f"Generating skeleton file {prettyPathString(outputPath)} from {prettyPathString(mdPath)}...", - ) - res = Result_generateSkeleton() - with ( - open(mdPath, "r", encoding="utf8") as mdFile, - open(outputPath, "w", encoding="utf8", newline="") as outputFile, - ): - for mdLine in preprocessMarkdownLines(mdFile.readlines()): - res.numTotalLines += 1 - skelLine = skeletonizeLine(mdLine) - if skelLine: - res.numTranslationPlaceholders += 1 - else: - skelLine = mdLine - outputFile.write(skelLine) - print( - f"Generated skeleton file with {res.numTotalLines} total lines and {res.numTranslationPlaceholders} translation placeholders", - ) - return res - - -@dataclass -class Result_updateSkeleton: - numAddedLines: int = 0 - numAddedTranslationPlaceholders: int = 0 - numRemovedLines: int = 0 - numRemovedTranslationPlaceholders: int = 0 - numUnchangedLines: int = 0 - numUnchangedTranslationPlaceholders: int = 0 - - -def extractSkeleton(xliffPath: str, outputPath: str): - print( - f"Extracting skeleton from {prettyPathString(xliffPath)} to {prettyPathString(outputPath)}...", - ) - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - outputFile.write(skeletonContent) - print(f"Extracted skeleton to {prettyPathString(outputPath)}") - - -def updateSkeleton( - origMdPath: str, - newMdPath: str, - origSkelPath: str, - outputPath: str, -) -> Result_updateSkeleton: - print( - f"Creating updated skeleton file {prettyPathString(outputPath)} from {prettyPathString(origSkelPath)} with changes from {prettyPathString(origMdPath)} to {prettyPathString(newMdPath)}...", - ) - res = Result_updateSkeleton() - with contextlib.ExitStack() as stack: - origMdFile = stack.enter_context(open(origMdPath, "r", encoding="utf8")) - newMdFile = stack.enter_context(open(newMdPath, "r", encoding="utf8")) - origSkelFile = stack.enter_context(open(origSkelPath, "r", encoding="utf8")) - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - origMdLines = preprocessMarkdownLines(origMdFile.readlines()) - newMdLines = preprocessMarkdownLines(newMdFile.readlines()) - mdDiff = difflib.ndiff(list(origMdLines), list(newMdLines)) - origSkelLines = iter(origSkelFile.readlines()) - for mdDiffLine in mdDiff: - if mdDiffLine.startswith("?"): - continue - if mdDiffLine.startswith(" "): - res.numUnchangedLines += 1 - skelLine = next(origSkelLines) - if re_translationID.match(skelLine): - res.numUnchangedTranslationPlaceholders += 1 - outputFile.write(skelLine) - elif mdDiffLine.startswith("+"): - res.numAddedLines += 1 - skelLine = skeletonizeLine(mdDiffLine[2:]) - if skelLine: - res.numAddedTranslationPlaceholders += 1 - else: - skelLine = mdDiffLine[2:] - outputFile.write(skelLine) - elif mdDiffLine.startswith("-"): - res.numRemovedLines += 1 - origSkelLine = next(origSkelLines) - if re_translationID.match(origSkelLine): - res.numRemovedTranslationPlaceholders += 1 - else: - raise ValueError(f"Unexpected diff line: {mdDiffLine}") - print( - f"Updated skeleton file with {res.numAddedLines} added lines " - f"({res.numAddedTranslationPlaceholders} translation placeholders), " - f"{res.numRemovedLines} removed lines ({res.numRemovedTranslationPlaceholders} translation placeholders), " - f"and {res.numUnchangedLines} unchanged lines ({res.numUnchangedTranslationPlaceholders} translation placeholders)", - ) - return res - - -@dataclass -class Result_generateXliff: - numTranslatableStrings: int = 0 - - -def generateXliff( - mdPath: str, - outputPath: str, - skelPath: str | None = None, -) -> Result_generateXliff: - # If a skeleton file is not provided, first generate one - with contextlib.ExitStack() as stack: - if not skelPath: - skelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=os.path.dirname(outputPath), - prefix=os.path.basename(mdPath), - suffix=".skel", - ), - ) - generateSkeleton(mdPath=mdPath, outputPath=skelPath) - with open(skelPath, "r", encoding="utf8") as skelFile: - skelContent = skelFile.read() - res = Result_generateXliff() - print( - f"Generating xliff file {prettyPathString(outputPath)} from {prettyPathString(mdPath)} and {prettyPathString(skelPath)}...", - ) - with contextlib.ExitStack() as stack: - mdFile = stack.enter_context(open(mdPath, "r", encoding="utf8")) - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - fileID = os.path.basename(mdPath) - mdUri = getRawGithubURLForPath(mdPath) - print(f"Including Github raw URL: {mdUri}") - outputFile.write( - '\n' - f'\n' - f'\n', - ) - outputFile.write(f"\n{xmlEscape(skelContent)}\n\n") - res.numTranslatableStrings = 0 - for lineNo, (mdLine, skelLine) in enumerate( - zip_longest( - preprocessMarkdownLines(mdFile.readlines()), - skelContent.splitlines(keepends=True), - ), - start=1, - ): - mdLine = mdLine.rstrip() - skelLine = skelLine.rstrip() - if m := re_translationID.match(skelLine): - res.numTranslatableStrings += 1 - prefix, ID, suffix = m.groups() - if prefix and not mdLine.startswith(prefix): - raise ValueError( - f'Line {lineNo}: does not start with "{prefix}", {mdLine=}, {skelLine=}', - ) - if suffix and not mdLine.endswith(suffix): - raise ValueError( - f'Line {lineNo}: does not end with "{suffix}", {mdLine=}, {skelLine=}', - ) - source = mdLine[len(prefix) : len(mdLine) - len(suffix)] - outputFile.write( - f'\n\nline: {lineNo + 1}\n', - ) - if prefix: - outputFile.write( - f'prefix: {xmlEscape(prefix)}\n', - ) - if suffix: - outputFile.write( - f'suffix: {xmlEscape(suffix)}\n', - ) - outputFile.write( - "\n" - f"\n" - f"{xmlEscape(source)}\n" - "\n" - "\n", # fmt: skip - ) - else: - if mdLine != skelLine: - raise ValueError( - f"Line {lineNo}: {mdLine=} does not match {skelLine=}", - ) - outputFile.write("\n") - print( - f"Generated xliff file with {res.numTranslatableStrings} translatable strings", - ) - return res - - -@dataclass -class Result_translateXliff: - numTranslatedStrings: int = 0 - - -def updateXliff( - xliffPath: str, - mdPath: str, - outputPath: str, -): - # uses generateMarkdown, extractSkeleton, updateSkeleton, and generateXliff to generate an updated xliff file. - outputDir = os.path.dirname(outputPath) - print( - f"Generating updated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} and {prettyPathString(mdPath)}...", - ) - with contextlib.ExitStack() as stack: - origMdPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="generated_", - suffix=".md", - ), - ) - generateMarkdown(xliffPath=xliffPath, outputPath=origMdPath, translated=False) - origSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="extracted_", - suffix=".skel", - ), - ) - extractSkeleton(xliffPath=xliffPath, outputPath=origSkelPath) - updatedSkelPath = stack.enter_context( - createAndDeleteTempFilePath_contextManager( - dir=outputDir, - prefix="updated_", - suffix=".skel", - ), - ) - updateSkeleton( - origMdPath=origMdPath, - newMdPath=mdPath, - origSkelPath=origSkelPath, - outputPath=updatedSkelPath, - ) - generateXliff( - mdPath=mdPath, - skelPath=updatedSkelPath, - outputPath=outputPath, - ) - print(f"Generated updated xliff file {prettyPathString(outputPath)}") - - -def translateXliff( - xliffPath: str, - lang: str, - pretranslatedMdPath: str, - outputPath: str, - allowBadAnchors: bool = False, -) -> Result_translateXliff: - print( - f"Creating {lang} translated xliff file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)} using {prettyPathString(pretranslatedMdPath)}...", - ) - res = Result_translateXliff() - with contextlib.ExitStack() as stack: - pretranslatedMdFile = stack.enter_context( - open(pretranslatedMdPath, "r", encoding="utf8"), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - xliffRoot.set("trgLang", lang) - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNo, (skelLine, pretranslatedLine) in enumerate( - zip_longest( - skeletonContent.splitlines(), - preprocessMarkdownLines(pretranslatedMdFile.readlines()), - ), - start=1, - ): - skelLine = skelLine.rstrip() - pretranslatedLine = pretranslatedLine.rstrip() - if m := re_translationID.match(skelLine): - prefix, ID, suffix = m.groups() - if prefix and not pretranslatedLine.startswith(prefix): - raise ValueError( - f'Line {lineNo} of translation does not start with "{prefix}", {pretranslatedLine=}, {skelLine=}', - ) - if suffix and not pretranslatedLine.endswith(suffix): - if allowBadAnchors and (m := re_heading.match(pretranslatedLine)): - print( - f"Warning: ignoring bad anchor in line {lineNo}: {pretranslatedLine}", - ) - suffix = m.group(3) - if suffix and not pretranslatedLine.endswith(suffix): - raise ValueError( - f'Line {lineNo} of translation: does not end with "{suffix}", {pretranslatedLine=}, {skelLine=}', - ) - translation = pretranslatedLine[len(prefix) : len(pretranslatedLine) - len(suffix)] - try: - unit = xliffRoot.find( - f'./xliff:file/xliff:unit[@id="{ID}"]', - namespaces=namespace, - ) - if unit is not None: - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is not None: - target = lxml.etree.Element("target") - target.text = translation - target.tail = "\n" - segment.append(target) - res.numTranslatedStrings += 1 - else: - raise ValueError(f"No segment found for unit {ID}") - else: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - except Exception as e: - e.add_note(f"Line {lineNo}: {pretranslatedLine=}, {skelLine=}") - raise - elif skelLine != pretranslatedLine: - raise ValueError( - f"Line {lineNo}: pretranslated line {pretranslatedLine!r}, does not match skeleton line {skelLine!r}", - ) - xliff.write(outputPath, encoding="utf8", xml_declaration=True) - print( - f"Translated xliff file with {res.numTranslatedStrings} translated strings", - ) - return res - - -@dataclass -class Result_generateMarkdown: - numTotalLines = 0 - numTranslatableStrings = 0 - numTranslatedStrings = 0 - numBadTranslationStrings = 0 - - -def generateMarkdown( - xliffPath: str, - outputPath: str, - translated: bool = True, -) -> Result_generateMarkdown: - print( - f"Generating markdown file {prettyPathString(outputPath)} from {prettyPathString(xliffPath)}...", - ) - res = Result_generateMarkdown() - with contextlib.ExitStack() as stack: - outputFile = stack.enter_context( - open(outputPath, "w", encoding="utf8", newline=""), - ) - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError("Not an xliff file") - skeletonNode = xliffRoot.find( - "./xliff:file/xliff:skeleton", - namespaces=namespace, - ) - if skeletonNode is None: - raise ValueError("No skeleton found in xliff file") - skeletonContent = skeletonNode.text.strip() - for lineNum, line in enumerate(skeletonContent.splitlines(keepends=True), 1): - res.numTotalLines += 1 - if m := re_translationID.match(line): - prefix, ID, suffix = m.groups() - res.numTranslatableStrings += 1 - unit = xliffRoot.find( - f'./xliff:file/xliff:unit[@id="{ID}"]', - namespaces=namespace, - ) - if unit is None: - raise ValueError(f"Cannot locate Unit {ID} in xliff file") - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - raise ValueError(f"No segment found for unit {ID}") - source = segment.find("./xliff:source", namespaces=namespace) - if source is None: - raise ValueError(f"No source found for unit {ID}") - translation = "" - if translated: - target = segment.find("./xliff:target", namespaces=namespace) - if target is not None: - targetText = target.text - if targetText: - translation = targetText - # Crowdin treats empty targets () as a literal translation. - # Filter out such strings and count them as bad translations. - if translation in ( - "", - "<target/>", - "", - "<target></target>", - ): - res.numBadTranslationStrings += 1 - translation = "" - else: - res.numTranslatedStrings += 1 - # If we have no translation, use the source text - if not translation: - sourceText = source.text - if sourceText is None: - raise ValueError(f"No source text found for unit {ID}") - translation = sourceText - outputFile.write(f"{prefix}{translation}{suffix}\n") - else: - outputFile.write(line) - print( - f"Generated markdown file with {res.numTotalLines} total lines, {res.numTranslatableStrings} translatable strings, and {res.numTranslatedStrings} translated strings. Ignoring {res.numBadTranslationStrings} bad translated strings", - ) - return res - - -def ensureMarkdownFilesMatch(path1: str, path2: str, allowBadAnchors: bool = False): - print( - f"Ensuring files {prettyPathString(path1)} and {prettyPathString(path2)} match...", - ) - with contextlib.ExitStack() as stack: - file1 = stack.enter_context(open(path1, "r", encoding="utf8")) - file2 = stack.enter_context(open(path2, "r", encoding="utf8")) - for lineNo, (line1, line2) in enumerate( - zip_longest( - preprocessMarkdownLines(file1.readlines()), - preprocessMarkdownLines(file2.readlines()), - ), - start=1, - ): - line1 = line1.rstrip() - line2 = line2.rstrip() - if line1 != line2: - if ( - re_postTableHeaderLine.match(line1) - and re_postTableHeaderLine.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of post table header line at line {lineNo}: {line1}, {line2}", - ) - continue - if ( - re_hiddenHeaderRow.match(line1) - and re_hiddenHeaderRow.match(line2) - and line1.count("|") == line2.count("|") - ): - print( - f"Warning: ignoring cell padding of hidden header row at line {lineNo}: {line1}, {line2}", - ) - continue - if allowBadAnchors and (m1 := re_heading.match(line1)) and (m2 := re_heading.match(line2)): - print( - f"Warning: ignoring bad anchor in headings at line {lineNo}: {line1}, {line2}", - ) - line1 = m1.group(1) + m1.group(2) - line2 = m2.group(1) + m2.group(2) - if line1 != line2: - raise ValueError( - f"Files do not match at line {lineNo}: {line1=} {line2=}", - ) - print("Files match") - - -def markdownTranslateCommand(command: str, *args): - print(f"Running markdownTranslate command: {command} {' '.join(args)}") - subprocess.run(["python", __file__, command, *args], check=True) - - -def pretranslateAllPossibleLanguages(langsDir: str, mdBaseName: str): - # This function walks through all language directories in the given directory, skipping en (English) and translates the English xlif and skel file along with the lang's pretranslated md file - enXliffPath = os.path.join(langsDir, "en", f"{mdBaseName}.xliff") - if not os.path.exists(enXliffPath): - raise ValueError(f"English xliff file {enXliffPath} does not exist") - allLangs = set() - succeededLangs = set() - skippedLangs = set() - for langDir in os.listdir(langsDir): - if langDir == "en": - continue - langDirPath = os.path.join(langsDir, langDir) - if not os.path.isdir(langDirPath): - continue - langPretranslatedMdPath = os.path.join(langDirPath, f"{mdBaseName}.md") - if not os.path.exists(langPretranslatedMdPath): - continue - allLangs.add(langDir) - langXliffPath = os.path.join(langDirPath, f"{mdBaseName}.xliff") - if os.path.exists(langXliffPath): - print(f"Skipping {langDir} as the xliff file already exists") - skippedLangs.add(langDir) - continue - try: - translateXliff( - xliffPath=enXliffPath, - lang=langDir, - pretranslatedMdPath=langPretranslatedMdPath, - outputPath=langXliffPath, - allowBadAnchors=True, - ) - except Exception as e: - print(f"Failed to translate {langDir}: {e}") - continue - rebuiltLangMdPath = os.path.join(langDirPath, f"rebuilt_{mdBaseName}.md") - try: - generateMarkdown( - xliffPath=langXliffPath, - outputPath=rebuiltLangMdPath, - ) - except Exception as e: - print(f"Failed to rebuild {langDir} markdown: {e}") - os.remove(langXliffPath) - continue - try: - ensureMarkdownFilesMatch( - rebuiltLangMdPath, - langPretranslatedMdPath, - allowBadAnchors=True, - ) - except Exception as e: - print( - f"Rebuilt {langDir} markdown does not match pretranslated markdown: {e}", - ) - os.remove(langXliffPath) - continue - os.remove(rebuiltLangMdPath) - print(f"Successfully pretranslated {langDir}") - succeededLangs.add(langDir) - if len(skippedLangs) > 0: - print(f"Skipped {len(skippedLangs)} languages already pretranslated.") - print( - f"Pretranslated {len(succeededLangs)} out of {len(allLangs) - len(skippedLangs)} languages.", - ) - - -if __name__ == "__main__": - mainParser = argparse.ArgumentParser() - commandParser = mainParser.add_subparsers( - title="commands", - dest="command", - required=True, - ) - generateXliffParser = commandParser.add_parser("generateXliff") - generateXliffParser.add_argument( - "-m", - "--markdown", - dest="md", - type=str, - required=True, - help="The markdown file to generate the xliff file for", - ) - generateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the xliff file to", - ) - updateXliffParser = commandParser.add_parser("updateXliff") - updateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The original xliff file", - ) - updateXliffParser.add_argument( - "-m", - "--newMarkdown", - dest="md", - type=str, - required=True, - help="The new markdown file", - ) - updateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the updated xliff to", - ) - translateXliffParser = commandParser.add_parser("translateXliff") - translateXliffParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to translate", - ) - translateXliffParser.add_argument( - "-l", - "--lang", - dest="lang", - type=str, - required=True, - help="The language to translate to", - ) - translateXliffParser.add_argument( - "-p", - "--pretranslatedMarkdown", - dest="pretranslatedMd", - type=str, - required=True, - help="The pretranslated markdown file to use", - ) - translateXliffParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the translated xliff file to", - ) - generateMarkdownParser = commandParser.add_parser("generateMarkdown") - generateMarkdownParser.add_argument( - "-x", - "--xliff", - dest="xliff", - type=str, - required=True, - help="The xliff file to generate the markdown file for", - ) - generateMarkdownParser.add_argument( - "-o", - "--output", - dest="output", - type=str, - required=True, - help="The file to output the markdown file to", - ) - generateMarkdownParser.add_argument( - "-u", - "--untranslated", - dest="translated", - action="store_false", - help="Generate the markdown file with the untranslated strings", - ) - ensureMarkdownFilesMatchParser = commandParser.add_parser( - "ensureMarkdownFilesMatch", - ) - ensureMarkdownFilesMatchParser.add_argument( - dest="path1", - type=str, - help="The first markdown file", - ) - ensureMarkdownFilesMatchParser.add_argument( - dest="path2", - type=str, - help="The second markdown file", - ) - pretranslateLangsParser = commandParser.add_parser("pretranslateLangs") - pretranslateLangsParser.add_argument( - "-d", - "--langs-dir", - dest="langsDir", - type=str, - required=True, - help="The directory containing the language directories", - ) - pretranslateLangsParser.add_argument( - "-b", - "--md-base-name", - dest="mdBaseName", - type=str, - required=True, - help="The base name of the markdown files to pretranslate", - ) - args = mainParser.parse_args() - match args.command: - case "generateXliff": - generateXliff(mdPath=args.md, outputPath=args.output) - case "updateXliff": - updateXliff( - xliffPath=args.xliff, - mdPath=args.md, - outputPath=args.output, - ) - case "generateMarkdown": - generateMarkdown( - xliffPath=args.xliff, - outputPath=args.output, - translated=args.translated, - ) - case "translateXliff": - translateXliff( - xliffPath=args.xliff, - lang=args.lang, - pretranslatedMdPath=args.pretranslatedMd, - outputPath=args.output, - ) - case "pretranslateLangs": - pretranslateAllPossibleLanguages( - langsDir=args.langsDir, - mdBaseName=args.mdBaseName, - ) - case "ensureMarkdownFilesMatch": - ensureMarkdownFilesMatch(path1=args.path1, path2=args.path2) - case _: - raise ValueError(f"Unknown command: {args.command}") diff --git a/.github/workflows/auto-update-translations.yaml b/.github/workflows/auto-update-translations.yaml deleted file mode 100644 index 61d57c6..0000000 --- a/.github/workflows/auto-update-translations.yaml +++ /dev/null @@ -1,14 +0,0 @@ -name: Auto update translations - -on: - push: - branches: - - main - schedule: - # * is a special character in YAML so you have to quote this string - - cron: '0 0 * * 6' - -jobs: - auto_update_translations: - uses: abdel792/autoUpdateTranslations/.github/workflows/l10n-updates.yaml@8ac89c644395cf2aad6e03a4bb2be5c36526d12a - \ No newline at end of file diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index 59f9ee1..ec1cc73 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Checkout repo - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -30,7 +30,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update -y - sudo apt-get install -y gettext + sudo apt-get install -y gettext libgtk-3-dev uv sync - name: Code checks @@ -60,7 +60,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: download all artifacts uses: actions/download-artifact@v8 with: diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 09b43f1..748ceb5 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -15,6 +15,7 @@ env: 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: @@ -31,7 +32,7 @@ jobs: Write-Host "Sleeping for $delaySeconds seconds..." Start-Sleep -Seconds $delaySeconds - name: Checkout add-on - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: submodules: true - name: Set up Python @@ -56,6 +57,7 @@ jobs: 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 }} diff --git a/.github/workflows/manual-release.yaml b/.github/workflows/manual-release.yaml index 2a896f6..1482278 100644 --- a/.github/workflows/manual-release.yaml +++ b/.github/workflows/manual-release.yaml @@ -150,8 +150,8 @@ jobs: with open("changelog.md", "w", encoding="utf-8") as f: f.write(text) - - shell: python + + shell: python - name: Check if there are any changes diff --git a/addon/doc/fr/readme.md b/addon/doc/fr/readme.md index b3673bd..f1a412b 100644 --- a/addon/doc/fr/readme.md +++ b/addon/doc/fr/readme.md @@ -46,7 +46,7 @@ Dans le panneau des paramètres de NVDA, juste après le champ "OpenRouter API K "Afficher la clé API" -Si elle est cochée, les caractères de la clé API deviennent visibles. +Si elle est cochée, les caractères de la clé API deviennent visibles. Par défaut, ils sont masqués pour des raisons de sécurité. ## Paramètres de sélection du modèle @@ -138,7 +138,7 @@ Si vous préférez n'afficher que la dernière réponse au lieu de l'historique ## Scripts non assignés -Les scripts suivants n'ont pas de raccourcis assignés. +Les scripts suivants n'ont pas de raccourcis assignés. Vous pouvez les définir dans : Préférences → Gestes de commandes → Ask OpenRouter diff --git a/addon/doc/tr/readme.md b/addon/doc/tr/readme.md index debcd90..09bf801 100644 --- a/addon/doc/tr/readme.md +++ b/addon/doc/tr/readme.md @@ -46,7 +46,7 @@ NVDA ayarları panelinde, "OpenRouter API Key" alanının hemen ardından şu et "Show API key" -İşaretlenirse API anahtarının karakterleri görünür hale gelir. +İşaretlenirse API anahtarının karakterleri görünür hale gelir. Varsayılan olarak güvenlik nedeniyle gizlenirler. ## Model Seçim Ayarları @@ -138,7 +138,7 @@ Konuşma geçmişinin tamamı yerine yalnızca en son yanıtı görüntülemeyi ## Atanmamış Komut Dosyaları -Aşağıdaki komutlara hareket atanmamıştır. +Aşağıdaki komutlara hareket atanmamıştır. Bunları şu yoldan tanımlayabilirsiniz: Tercihler → Girdi Hareketleri → Ask OpenRouter diff --git a/addon/doc/uk/readme.md b/addon/doc/uk/readme.md new file mode 100644 index 0000000..c2bcd6c --- /dev/null +++ b/addon/doc/uk/readme.md @@ -0,0 +1,196 @@ +# Запитати OpenRouter + +* Автор: Abdel. + +Цей додаток для NVDA дозволяє взаємодіяти з моделями штучного інтелекту, що надаються платформою OpenRouter, безпосередньо з екранного зчитувача. + +Цей додаток підтримує обидві функції: +* Автоматичний випадковий вибір безкоштовних моделей +* Ручний вибір будь-якої доступної моделі (включно з платними) + +## Основні функції + +* Швидкий доступ: відкривайте інтерфейс чату в будь-який час за допомогою глобальної комбінації клавіш. +* Керування розмовами: розпочинайте нову розмову або продовжуйте попередню. +* Розумна зміна безкоштовних моделей: автоматичний вибір випадкової безкоштовної моделі для оптимізації щоденних квот використання. +* Ручний вибір моделі: обирайте конкретну модель (включаючи платні) у панелі налаштувань. +* Зручний перегляд результатів: переглядайте відповіді у чіткому, зручному для навігації діалозі з можливістю показу повної історії. + +## Конфігурація: отримання та встановлення ключа API + +Щоб використовувати цей додаток, ви повинні мати ключ API від OpenRouter. + +Навіть при використанні безкоштовних моделей ключ потрібен для ідентифікації ваших запитів. + +### 1. Як отримати ключ API + +1. Перейдіть на сайт [OpenRouter.ai](https://openrouter.ai/). +2. Створіть обліковий запис, натиснувши «Зареєструватися» (ви можете увійти за допомогою облікового запису GitHub, Google або MetaMask, а також за допомогою своєї електронної адреси). +3. Після входу перейдіть до розділу «Ключі» на вашій панелі керування або перейдіть безпосередньо за посиланням: https://openrouter.ai/keys +4. Натисніть кнопку «Створити ключ». +5. Дайте своєму ключу назву (наприклад: «Мій ключ API OpenRouter») і натисніть «Створити». +6. Важливо: Ваш ключ буде показано лише один раз. Негайно скопіюйте його та збережіть у безпечному місці. + +### 2. Налаштування ключа в NVDA + +1. Відкрийте меню NVDA (клавіші NVDA + N). +2. Перейдіть до розділу «Параметри», а потім — «Налаштування». +3. У списку категорій виберіть «Запитати OpenRouter». +4. Вставте свій ключ API у поле «Ключ API OpenRouter». +5. Натисніть «OK», щоб зберегти. + +#### Показати ключ API + +На панелі налаштувань NVDA, одразу після поля «Ключ API OpenRouter», розташований прапорець із назвою: + +«Показати ключ API» + +Якщо позначити прапорець, символи API-ключа стануть видимими. +Початково вони приховані з міркувань безпеки. + +## Параметри вибору моделі + +У розділі налаштувань «Запитати OpenRouter» ви знайдете новий параметр: + +### "Використовувати всі моделі, включаючи платні" + +Цей параметр керує способом вибору моделей. + +### Коли параметр НЕ ПОЗНАЧЕНО (поведінка початкова) + +* Додаток автоматично вибирає випадкову безкоштовну модель для кожного нового діалогу. +* Він по черзі використовує доступні безкоштовні моделі. +* Це допомагає рівномірно розподілити навантаження та уникнути обмежень за швидкістю. + +### Коли параметр ПОЗНАЧЕНО + +Якщо цей параметр позначено, після прапорця автоматично з’являється список доступних моделей. + +* Список відсортовано за зростанням ціни за вхідний токен (вартість одного токена запиту), від найнижчої до найвищої. +* Показуються лише неприховані моделі з дійсними постачальниками. + +### Що можна робити, коли цей параметр увімкнено? + +* Оберіть будь-яку доступну модель. +* Використовуйте платні моделі (якщо у вас достатньо кредитів OpenRouter). +* Оберіть модель, яка найкраще відповідає вашим потребам. +* Продовжуйте використовувати одну й ту саму обрану модель для своїх розмов (без автоматичної зміни). + +### Що таке підказка? + +Токен підказки — це невеликий фрагмент тексту, що надсилається до моделі (ваше запитання або вхідні дані). + +Зазвичай з моделей окремо стягується плата за: +* Токени вхідних даних (підказка) +* Токени вихідних даних (завершення) + +## Як використовувати + +### Відкриття діалогу чату + +Натисніть: + +Ctrl + Alt + A + +Ви можете змінити цей жест у: +Меню NVDA → Параметри → Жести вводу → Запитати OpenRouter + +### Основний інтерфейс + +Діалог містить три кнопки: + +1. Новий чат — розпочинає нову розмову. +2. Продовжити чат — продовжує попередню розмову (історія зберігається). +3. Закрити — закриває діалогове вікно (також можна натиснути клавішу Escape). + +### Введення підказки + +Вибравши «Новий чат» або «Продовжити чат»: + +* З'явиться багаторядкове текстове поле. +* Натискання клавіші Enter вставляє новий рядок. +* Щоб надіслати повідомлення: + - Натисніть клавішу Tab, щоб перейти до кнопки «OK». + - Натисніть клавішу Enter. + +### Читання відповіді + +Після обробки з'являється вікно з результатами, яке містить: + +* «Ви сказали:», а потім — ваше повідомлення. +* «Відповідь моделі:», а потім — відповідь. +* Кнопка «Копіювати» для копіювання відповіді. + +Якщо ввімкнено показ повної історії, кожен обмін чітко розділено заголовками, що полегшує навігацію за допомогою клавіш швидкої навігації NVDA. + +## Параметри показу + +Якщо ви бажаєте показувати лише останню відповідь замість повної історії розмов: + +1. Відкрийте меню NVDA (NVDA + N). +2. Перейдіть до Параметри → Налаштування. +3. Виберіть Запитати OpenRouter. +4. Зніміть прапорець: + «Показувати повну історію чату для безперервних обговорень» +5. Натисніть OK. + +## Непризначені сценарії + +Нижченаведені сценарії не мають жестів. +Ви можете призначити їх у: + +Параметри → Жести вводу → Запитати OpenRouter + +Доступні сценарії: + +* Відкрити панель налаштувань додатка +* Почати новий чат безпосередньо +* Продовжити наявний чат безпосередньо + +## Безкоштовні моделі, платні моделі та квоти + +### Безкоштовне використання моделі + +Коли не позначено прапорець «Використовувати всі моделі, включаючи платні»: + +* Використовуються лише моделі, позначені на OpenRouter як безкоштовні. +* Безкоштовні моделі мають: + - Обмежені добові квоти + - Спільні обмеження швидкості + - Можливу тимчасову недоступність + +Додаток автоматично змінює безкоштовні моделі для підвищення доступності. + +### Платне використання моделі + +Коли позначено прапорець «Використовувати всі моделі, включаючи платні»: + +* Додаток використовує саме ту модель, яку ви обрали. +* Це можуть бути й платні моделі. +* У вас має бути достатньо кредитів OpenRouter. +* Можуть застосовуватися обмеження постачальника щодо швидкості. + +Такі помилки, як: +* 402 (недостатньо кредитів) +* 429 (обмеження частоти запитів) +* 404 (модель заборонена налаштуваннями конфіденційності) + +показуються безпосередньо, щоб повідомити вас про проблему. + +## Нагадування про налаштування конфіденційності + +Якщо ви використовуєте безкоштовні моделі та отримуєте повідомлення про помилку: + +> "Не знайдено кінцевих точок, які відповідають вашій політиці даних" + +Можливо, вам знадобиться змінити налаштування конфіденційності OpenRouter: + +https://openrouter.ai/settings/privacy + +Переконайтеся, що публічні/безкоштовні кінцеві точки моделі дозволені. + +## Сумісність ## + +* Цей додаток сумісний із версіями NVDA починаючи з 2025.1 і вище. + +* Переклад: Георгій Галас. diff --git a/addon/globalPlugins/askOpenRouter/__init__.py b/addon/globalPlugins/askOpenRouter/__init__.py index b5d147a..20162fb 100644 --- a/addon/globalPlugins/askOpenRouter/__init__.py +++ b/addon/globalPlugins/askOpenRouter/__init__.py @@ -1,3 +1,4 @@ +# pyright: reportUnusedCallResult=false # globalPlugins/askOpenRouter/__init__.py # Copyright(C) 2026-2028 Abdel @@ -5,13 +6,15 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import scriptHandler import config import wx import addonHandler import globalPluginHandler import gui -from typing import Callable +import typing +from typing import Any, cast from .dialogs import addonSummary, OpenRouterSettingsPanel, ChatDialog from gui.settingsDialogs import NVDASettingsDialog from .functions import disableInSecureMode @@ -25,63 +28,82 @@ class GlobalPlugin(globalPluginHandler.GlobalPlugin): scriptCategory = addonSummary - def __init__(self, *args, **kwargs): + def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) - if "askOpenRouter" not in config.conf.spec: - config.conf.spec["askOpenRouter"] = { + # Use cast(Any) to prevent reportUnknownMemberType on config.conf.spec + conf_any = cast(Any, config.conf) + if "askOpenRouter" not in conf_any.spec: + conf_any.spec["askOpenRouter"] = { "apiKey": "string(default='')", "fullHistory": "boolean(default=True)", "useAllModels": "boolean(default=False)", - "selectedModel": "string(default='')" + "selectedModel": "string(default='')", } gui.settingsDialogs.NVDASettingsDialog.categoryClasses.append( - OpenRouterSettingsPanel + OpenRouterSettingsPanel, ) - def terminate(self): + @typing.override + def terminate(self) -> None: if OpenRouterSettingsPanel in gui.settingsDialogs.NVDASettingsDialog.categoryClasses: gui.settingsDialogs.NVDASettingsDialog.categoryClasses.remove( - OpenRouterSettingsPanel + OpenRouterSettingsPanel, ) - def onChatDialog(self, evt): - gui.mainFrame.prePopup() - dialog = ChatDialog(gui.mainFrame) + def onChatDialog(self, evt: Any) -> None: + # Guard against Optional (MainFrame | None) type on gui.mainFrame + mainFrame = gui.mainFrame + if mainFrame is None: + return + + mainFrame.prePopup() + dialog = ChatDialog(mainFrame) dialog.Show() - gui.mainFrame.postPopup() + mainFrame.postPopup() @scriptHandler.script( # Translators: Description of the script which opens the interaction dialog box with OpenRouter. description=_("Opens a dialog allowing the user to ask questions to OpenRouter"), gesture="kb:control+alt+a", ) - def script_openRouterDialog(self, gesture): - wx.CallAfter(self.onChatDialog, gui.mainFrame) + def script_openRouterDialog(self, gesture: Any) -> None: + wx.CallAfter(self.onChatDialog, None) @scriptHandler.script( # Translators: Description of the script which allows to create a new chat. description=_("Opens the prompt to start a new OpenRouter chat."), ) - def script_newChat(self, gesture): - dialog = ChatDialog(gui.mainFrame) - dialog.onNew(None) + def script_newChat(self, gesture: Any) -> None: + mainFrame = gui.mainFrame + if mainFrame is None: + return + dialog = ChatDialog(mainFrame) + dialog.onNew(cast(wx.CommandEvent, None)) @scriptHandler.script( # Translators: Description of the script which allows to continue an existing chat. description=_("Opens the prompt to continue an existing OpenRouter chat."), ) - def script_continueChat(self, gesture): - dialog = ChatDialog(gui.mainFrame) - dialog.onContinue(None) + def script_continueChat(self, gesture: Any) -> None: + mainFrame = gui.mainFrame + if mainFrame is None: + return + dialog = ChatDialog(mainFrame) + dialog.onContinue(cast(wx.CommandEvent, None)) @scriptHandler.script( # Translators: Description of the script which allows to show OpenRouter settings panel.. description=_("Opens the add-on settings panel."), ) - def script_showOpenRouterSettingsPanel(self, gesture): - gui.mainFrame.popupSettingsDialog( + def script_showOpenRouterSettingsPanel(self, gesture: Any) -> None: + mainFrame = gui.mainFrame + if mainFrame is None: + return + + # Use cast(Any) to bypass the partially unknown type of popupSettingsDialog + cast(Any, mainFrame).popupSettingsDialog( NVDASettingsDialog, - OpenRouterSettingsPanel + OpenRouterSettingsPanel, ) diff --git a/addon/globalPlugins/askOpenRouter/dialogs.py b/addon/globalPlugins/askOpenRouter/dialogs.py index 621ad41..dbc23a5 100644 --- a/addon/globalPlugins/askOpenRouter/dialogs.py +++ b/addon/globalPlugins/askOpenRouter/dialogs.py @@ -1,13 +1,16 @@ +# pyright: reportUnusedCallResult=false # globalPlugins/askOpenRouter/dialogs.py # Copyright(C) 2026-2028 Abdel # Released under GPL 2 +from collections.abc import Callable +import typing +from typing import Any, cast import wx import addonHandler import config import gui -from typing import Callable, List, Dict, Optional, cast from gui.settingsDialogs import SettingsPanel from .functions import askOpenRouter, inputBox, getAvailableModels @@ -15,7 +18,7 @@ addonHandler.initTranslation() _: Callable[[str], str] -addonSummary: str = addonHandler.getCodeAddon().manifest["summary"] +addonSummary: str = cast(str, cast(Any, addonHandler).getCodeAddon().manifest["summary"]) # Chat Dialog @@ -29,9 +32,9 @@ class ChatDialog(wx.Dialog): - Close the dialog """ - _instance: Optional["ChatDialog"] = None + _instance: "ChatDialog | None" = None - def __new__(cls, *args, **kwargs) -> "ChatDialog": + def __new__(cls, *args: Any, **kwargs: Any) -> "ChatDialog": if not ChatDialog._instance: return super().__new__(cls, *args, **kwargs) return ChatDialog._instance @@ -51,49 +54,48 @@ def __init__(self, parent: wx.Window) -> None: super().__init__( parent, # Translators: Title of the dialog box. - title=_("Chat Manager") + title=_("Chat Manager"), ) mainSizer: wx.BoxSizer = wx.BoxSizer(wx.VERTICAL) sHelper: gui.guiHelper.BoxSizerHelper = gui.guiHelper.BoxSizerHelper( self, - wx.VERTICAL + wx.VERTICAL, ) - buttonGroup: gui.guiHelper.ButtonHelper = \ - gui.guiHelper.ButtonHelper(wx.HORIZONTAL) + buttonGroup: gui.guiHelper.ButtonHelper = gui.guiHelper.ButtonHelper(wx.HORIZONTAL) self.newButton: wx.Button = buttonGroup.addButton( self, # Translators: Prompt label to create a new chat. - label=_("C&reate a New Chat") + label=_("C&reate a New Chat"), ) self.continueButton: wx.Button = buttonGroup.addButton( self, # Translators: Prompt label to continue an existing chat. - label=_("Co&ntinue a Chat") + label=_("Co&ntinue a Chat"), ) self.closeButton: wx.Button = buttonGroup.addButton( self, # Translators: Label of the closing button. - label=_("&Close") + label=_("&Close"), ) self.newButton.SetDefault() self.SetEscapeId(self.closeButton.GetId()) - self.newButton.Bind(wx.EVT_BUTTON, self.onNew) - self.continueButton.Bind(wx.EVT_BUTTON, self.onContinue) - self.Bind(wx.EVT_BUTTON, self.onClose, self.closeButton) + self.newButton.Bind(wx.EVT_BUTTON, self.onNew) # type: ignore + self.continueButton.Bind(wx.EVT_BUTTON, self.onContinue) # type: ignore + self.Bind(wx.EVT_BUTTON, self.onClose, self.closeButton) # type: ignore sHelper.addItem(buttonGroup) - mainSizer.Add( + mainSizer.Add( # type: ignore sHelper.sizer, border=gui.guiHelper.BORDER_FOR_DIALOGS, - flag=wx.ALL | wx.EXPAND + flag=wx.ALL | wx.EXPAND, ) self.SetSizerAndFit(mainSizer) @@ -116,7 +118,7 @@ def onNew(self, evt: wx.CommandEvent) -> None: inputBox( # Translators: Title of the dialog box to start a new chat. _("New Chat"), - askOpenRouter + askOpenRouter, ) def onContinue(self, evt: wx.CommandEvent) -> None: @@ -127,11 +129,10 @@ def onContinue(self, evt: wx.CommandEvent) -> None: # Translators: Title of the dialog box to continue an existing chat. _("Continue Chat"), askOpenRouter, - new=False + new=False, ) - # Settings Panel class OpenRouterSettingsPanel(SettingsPanel): """ @@ -147,15 +148,15 @@ class OpenRouterSettingsPanel(SettingsPanel): title: str = addonSummary - def makeSettings(self, settingsSizer: wx.Sizer) -> None: + @typing.override + def makeSettings(self, sizer: wx.Sizer) -> None: """ Build the settings UI components. Args: - settingsSizer (wx.Sizer): Parent sizer provided by NVDA. + sizer (wx.Sizer): Parent sizer provided by NVDA. """ - self.sHelper: gui.guiHelper.BoxSizerHelper = \ - gui.guiHelper.BoxSizerHelper(self, sizer=settingsSizer) + self.sHelper: gui.guiHelper.BoxSizerHelper = gui.guiHelper.BoxSizerHelper(self, sizer=sizer) # ========================= # API KEY @@ -164,18 +165,21 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: self.apiKeyLabel: wx.StaticText = wx.StaticText( self, # Translators: Label of the field that must contain the OpenRouter API key. - label=_("OpenRouter API Key:") + label=_("OpenRouter API Key:"), ) self.sHelper.addItem(self.apiKeyLabel) self.apiKeyHidden: wx.TextCtrl = wx.TextCtrl( self, - style=wx.TE_PASSWORD + style=wx.TE_PASSWORD, ) self.sHelper.addItem(self.apiKeyHidden, flag=wx.EXPAND) + # OpenRouter configuration dictionary + conf_dict = cast(dict[str, Any], config.conf["askOpenRouter"]) + self.apiKeyHidden.SetValue( - config.conf["askOpenRouter"]["apiKey"] + cast(str, conf_dict["apiKey"]), ) self.apiKeyVisible: wx.TextCtrl = wx.TextCtrl(self) @@ -185,13 +189,13 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: self.showApiKeyCheckBox: wx.CheckBox = wx.CheckBox( self, # Translators: Label of the checkbox to display the OpenRouter API key. - label=_("Show API key") + label=_("Show API key"), ) self.sHelper.addItem(self.showApiKeyCheckBox) - self.showApiKeyCheckBox.Bind( + self.showApiKeyCheckBox.Bind( # type: ignore wx.EVT_CHECKBOX, - self.onToggleApiVisibility + self.onToggleApiVisibility, ) # ========================= @@ -201,12 +205,12 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: self.fullHistoryCheckBox: wx.CheckBox = wx.CheckBox( self, # Translators: Label of the checkbox to display chat history. - label=_("Display the full chat history for continuous discussions") + label=_("Display the full chat history for continuous discussions"), ) self.sHelper.addItem(self.fullHistoryCheckBox) self.fullHistoryCheckBox.SetValue( - config.conf["askOpenRouter"]["fullHistory"] + cast(bool, conf_dict["fullHistory"]), ) # ========================= @@ -215,19 +219,18 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: self.useAllModelsCheckBox: wx.CheckBox = wx.CheckBox( self, - # Translators: # Translators: Label of the checkbox to show the list of models, including paid ones. - label=_("Use all models, including paid ones.") + label=_("Use all models, including paid ones."), ) self.sHelper.addItem(self.useAllModelsCheckBox) self.useAllModelsCheckBox.SetValue( - config.conf["askOpenRouter"].get("useAllModels", False) + cast(bool, conf_dict.get("useAllModels", False)), ) - self.useAllModelsCheckBox.Bind( + self.useAllModelsCheckBox.Bind( # type: ignore wx.EVT_CHECKBOX, - self.onToggleModelsList + self.onToggleModelsList, ) # ========================= @@ -238,7 +241,7 @@ def makeSettings(self, settingsSizer: wx.Sizer) -> None: self.sHelper.addItem(self.modelsList, flag=wx.EXPAND) self.modelsList.Hide() - self.modelsData: List[Dict[str, object]] = [] + self.modelsData: list[dict[str, object]] = [] wx.CallAfter(self.onToggleModelsList, None) @@ -259,7 +262,7 @@ def onToggleApiVisibility(self, evt: wx.CommandEvent) -> None: self.Layout() - def onToggleModelsList(self, evt: Optional[wx.CommandEvent]) -> None: + def onToggleModelsList(self, evt: wx.CommandEvent | None) -> None: """ Show or hide the models list depending on checkbox state. """ @@ -289,8 +292,7 @@ def _loadModelsIfNeeded(self) -> None: return try: - models: List[Dict[str, object]] = \ - getAvailableModels(apiKey) + models: list[dict[str, object]] = getAvailableModels(apiKey) except Exception: return @@ -298,7 +300,7 @@ def _loadModelsIfNeeded(self) -> None: self.modelsData = models - displayNames: List[str] = [] + displayNames: list[str] = [] for m in models: price: float = cast(float, m["promptPricing"]) @@ -307,10 +309,8 @@ def _loadModelsIfNeeded(self) -> None: self.modelsList.Set(displayNames) - savedModel: str = config.conf["askOpenRouter"].get( - "selectedModel", - "" - ) + conf_dict = cast(dict[str, Any], config.conf["askOpenRouter"]) + savedModel: str = cast(str, conf_dict.get("selectedModel", "")) for index, m in enumerate(models): if m["id"] == savedModel: @@ -328,21 +328,18 @@ def getApiKeyValue(self) -> str: return self.apiKeyVisible.GetValue() return self.apiKeyHidden.GetValue() + @typing.override def onSave(self) -> None: """ Save settings into NVDA configuration. """ - config.conf["askOpenRouter"]["apiKey"] = \ - self.getApiKeyValue().strip() - - config.conf["askOpenRouter"]["fullHistory"] = \ - self.fullHistoryCheckBox.GetValue() + conf_dict = cast(dict[str, Any], config.conf["askOpenRouter"]) - config.conf["askOpenRouter"]["useAllModels"] = \ - self.useAllModelsCheckBox.GetValue() + conf_dict["apiKey"] = self.getApiKeyValue().strip() + conf_dict["fullHistory"] = self.fullHistoryCheckBox.GetValue() + conf_dict["useAllModels"] = self.useAllModelsCheckBox.GetValue() index: int = self.modelsList.GetSelection() if index != wx.NOT_FOUND and self.modelsData: - config.conf["askOpenRouter"]["selectedModel"] = \ - self.modelsData[index]["id"] \ No newline at end of file + conf_dict["selectedModel"] = self.modelsData[index]["id"] diff --git a/addon/globalPlugins/askOpenRouter/functions.py b/addon/globalPlugins/askOpenRouter/functions.py index ef05d81..dceb68d 100644 --- a/addon/globalPlugins/askOpenRouter/functions.py +++ b/addon/globalPlugins/askOpenRouter/functions.py @@ -1,3 +1,4 @@ +# pyright: reportUnusedCallResult=false # globalPlugins/askOpenRouter/functions.py # Copyright(C) 2026-2028 Abdel @@ -5,6 +6,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +from collections.abc import Callable import wx import globalPluginHandler import globalVars @@ -20,22 +22,23 @@ import urllib.request import urllib.error import time -from typing import List, Dict, Callable, Optional, Any +from typing import Any, cast + addonHandler.initTranslation() _: Callable[[str], str] # Temporary in-memory blacklist for unavailable models -_unavailableModels: Dict[str, float] = {} +_unavailableModels: dict[str, float] = {} # Cooldowns (seconds) -_RATE_LIMIT_COOLDOWN: int = 300 # 429 -_POLICY_COOLDOWN: int = 180 # 404 -_PAYMENT_COOLDOWN: int = 1800 # 402 +_RATE_LIMIT_COOLDOWN: int = 300 # 429 +_POLICY_COOLDOWN: int = 180 # 404 +_PAYMENT_COOLDOWN: int = 1800 # 402 -def disableInSecureMode(decoratedCls): +def disableInSecureMode(decoratedCls: Any) -> Any: """ Decorator created by Luke Davis, member of the nvda-addons mailing list. """ @@ -75,12 +78,12 @@ def loadModel(filename: str) -> str: return "" -def saveHistory(history: List[Dict[str, str]], filename: str) -> None: +def saveHistory(history: list[dict[str, str]], filename: str) -> None: """ Serialize and save conversation history to disk. Args: - history (List[Dict[str, str]]): Conversation messages. + history (list[dict[str, str]]): Conversation messages. filename (str): Destination file path. Returns: @@ -90,7 +93,7 @@ def saveHistory(history: List[Dict[str, str]], filename: str) -> None: pickle.dump(history, f) -def loadHistory(filename: str) -> List[Dict[str, str]]: +def loadHistory(filename: str) -> list[dict[str, str]]: """ Load serialized conversation history from disk. @@ -98,7 +101,7 @@ def loadHistory(filename: str) -> List[Dict[str, str]]: filename (str): Path to history file. Returns: - List[Dict[str, str]]: Loaded conversation history, or an empty list. + list[dict[str, str]]: Loaded conversation history, or an empty list. """ if os.path.exists(filename): with open(filename, "rb") as f: @@ -115,10 +118,7 @@ def _cleanupUnavailableModels() -> None: """ currentTime: float = time.time() - expired = [ - model for model, expiry in _unavailableModels.items() - if currentTime > expiry - ] + expired = [model for model, expiry in _unavailableModels.items() if currentTime > expiry] for model in expired: del _unavailableModels[model] @@ -173,7 +173,7 @@ def getRandomFreeModel(apiKey: str) -> str: modelsURL: str = "https://openrouter.ai/api/v1/models" - headers: Dict[str, str] = { + headers: dict[str, str] = { "Authorization": f"Bearer {apiKey}", "User-Agent": "Python-urllib", } @@ -185,7 +185,7 @@ def getRandomFreeModel(apiKey: str) -> str: models = data["data"] - candidates: List[str] = [ + candidates: list[str] = [ m["id"] for m in models if float(m.get("pricing", {}).get("prompt", 1)) == 0 @@ -203,7 +203,7 @@ def getRandomFreeModel(apiKey: str) -> str: return random.choice(candidates) -def getAvailableModels(apiKey: str) -> List[Dict[str, object]]: +def getAvailableModels(apiKey: str) -> list[dict[str, object]]: """ Retrieve the full list of available models for the current user. @@ -226,7 +226,7 @@ def getAvailableModels(apiKey: str) -> List[Dict[str, object]]: apiKey (str): OpenRouter API key. Returns: - List[Dict[str, object]]: List of available model metadata. + list[dict[str, object]]: List of available model metadata. Raises: urllib.error.URLError: If network request fails. @@ -234,7 +234,7 @@ def getAvailableModels(apiKey: str) -> List[Dict[str, object]]: """ modelsURL: str = "https://openrouter.ai/api/v1/models" - headers: Dict[str, str] = { + headers: dict[str, str] = { "Authorization": f"Bearer {apiKey}", "User-Agent": "Python-urllib", } @@ -246,7 +246,7 @@ def getAvailableModels(apiKey: str) -> List[Dict[str, object]]: models = data.get("data", []) - availableModels: List[Dict[str, object]] = [] + availableModels: list[dict[str, object]] = [] for m in models: if m.get("deprecated", False): @@ -255,25 +255,27 @@ def getAvailableModels(apiKey: str) -> List[Dict[str, object]]: if not m.get("top_provider") or not m.get("context_length"): continue - availableModels.append({ - "id": m.get("id"), - "promptPricing": float(m.get("pricing", {}).get("prompt", 0)), - "completionPricing": float(m.get("pricing", {}).get("completion", 0)), - "contextLength": m.get("context_length"), - "deprecated": m.get("deprecated", False), - }) + availableModels.append( + { + "id": m.get("id"), + "promptPricing": float(m.get("pricing", {}).get("prompt", 0)), + "completionPricing": float(m.get("pricing", {}).get("completion", 0)), + "contextLength": m.get("context_length"), + "deprecated": m.get("deprecated", False), + }, + ) return availableModels -def _sendRequest(url: str, headers: Dict[str, str], data: Dict) -> str: +def _sendRequest(url: str, headers: dict[str, str], data: dict[str, Any]) -> str: """ Send an HTTP POST request to OpenRouter. Args: url (str): Endpoint URL. - headers (Dict[str, str]): HTTP headers. - data (Dict): JSON payload. + headers (dict[str, str]): HTTP headers. + data (dict[str, Any]): JSON payload. Returns: str: Assistant response text. @@ -326,12 +328,12 @@ def getHistory(filename: str) -> str: str: HTML-formatted conversation history, or an empty string if no history exists. """ - historyLines: List[str] = [] - allChat: List[Dict[str, str]] = [] + historyLines: list[str] = [] + allChat: list[dict[str, str]] = [] # Translators: Message announcing what the user said. - userQuestion: str = _('You said:') + userQuestion: str = _("You said:") # Translators: Message announcing what the model responded. - modelResponse: str = _('Model replied:') + modelResponse: str = _("Model replied:") if os.path.exists(filename): with open(filename, "rb") as f: @@ -375,12 +377,15 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: url: str = "https://openrouter.ai/api/v1/chat/completions" - addonPath: str = addonHandler.getCodeAddon().path + # Apply typing fix matching dialogs.py to avoid unknown member and variable types + addonPath: str = cast(Any, addonHandler).getCodeAddon().path historyFile: str = os.path.join(addonPath, "open_router_history.pkl") modelFile: str = os.path.join(addonPath, "model.txt") - useAll: bool = config.conf["askOpenRouter"].get("useAllModels", False) - selectedModel: str = config.conf["askOpenRouter"].get("selectedModel", "") + # Cast configuration elements to dictionary to solve dictionary access type errors + conf_dict = cast(dict[str, Any], config.conf["askOpenRouter"]) + useAll: bool = conf_dict.get("useAllModels", False) + selectedModel: str = conf_dict.get("selectedModel", "") # Reset conversation if requested if new: @@ -405,7 +410,7 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: # Translators: Message informing that no free models are available. _("No free model available at the moment."), # Translators: Title of the error message. - title=_("Model Error") + title=_("Model Error"), ) return @@ -423,32 +428,34 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: # Translators: Message informing that no free models are available. _("No free model available at the moment."), # Translators: Title of the error message. - title=_("Model Error") + title=_("Model Error"), ) return - history: List[Dict[str, str]] = loadHistory(historyFile) + history: list[dict[str, str]] = loadHistory(historyFile) - history.append({ - "role": "user", - "content": prompt - }) + history.append( + { + "role": "user", + "content": prompt, + }, + ) - headers: Dict[str, str] = { + headers: dict[str, str] = { "Authorization": f"Bearer {apiKey}", "Content-Type": "application/json", "HTTP-Referer": "http://localhost", - "X-Title": "My question" + "X-Title": "My question", } - data: Dict[str, Any] = { + data: dict[str, Any] = { "model": model, - "messages": history + "messages": history, } maxAttempts: int = 5 attempt: int = 0 - answer: Optional[str] = None + answer: str | None = None while attempt < maxAttempts: try: @@ -456,13 +463,12 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: break except urllib.error.HTTPError as e: - # If the user uses their own paid model, do not fallback if useAll: ui.browseableMessage( f"HTTP Error: {e.code}, {e.read().decode('utf-8')}", # Translators: Title of the HTTP error message. - title=_("HTTP Error") + title=_("HTTP Error"), ) return @@ -483,7 +489,7 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: ui.browseableMessage( f"HTTP Error: {e.code}, {e.read().decode('utf-8')}", # Translators: Title of the HTTP error message. - title=_("HTTP Error") + title=_("HTTP Error"), ) return @@ -491,7 +497,7 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: ui.browseableMessage( # Translators: Network error message. message=f"{_('Network error:')} {e.reason}", - title="Network Error" + title="Network Error", ) return @@ -500,20 +506,22 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: # Translators: Message informing that no free models are available at the moment. _("All free models are currently unavailable. Please try again later."), # Translators: Title of the model unavailable error. - title=_("Model Unavailable") + title=_("Model Unavailable"), ) return - history.append({ - "role": "assistant", - "content": answer - }) + history.append( + { + "role": "assistant", + "content": answer, + }, + ) saveHistory(history, historyFile) answerHtml: str = markdownToHtml(answer) - if config.conf["askOpenRouter"]["fullHistory"]: + if conf_dict["fullHistory"]: messageToDisplay: str = getHistory(historyFile) else: messageToDisplay = answerHtml @@ -523,14 +531,14 @@ def askOpenRouter(prompt: str, apiKey: str, new: bool = True) -> None: # Translators: Title of the model response message. title=_("Model Response"), isHtml=True, - copyButton=True + copyButton=True, ) def inputBox( - title: str, - func: Callable[[str, str, bool], None], - new: bool = True + title: str, + func: Callable[[str, str, bool], None], + new: bool = True, ) -> None: """ Display a multiline input dialog and send the result to the given function. @@ -555,7 +563,7 @@ def inputBox( # Translators: Message inviting the user to enter his question. _("Please enter the question you want to ask OpenRouter"), title, - style=wx.TE_MULTILINE | wx.OK | wx.CANCEL + style=wx.TE_MULTILINE | wx.OK | wx.CANCEL, ) def callback(result: int) -> None: @@ -565,25 +573,26 @@ def callback(result: int) -> None: # Translators: Message informing the user that the field is empty, he must fill it in. message=_("You did not enter anything. Please try again."), # Translators: Title of the error message. - caption=_("Input Error") + caption=_("Input Error"), ) return - apiKey: str = config.conf["askOpenRouter"]["apiKey"] + conf_dict = cast(dict[str, Any], config.conf["askOpenRouter"]) + apiKey: str = conf_dict["apiKey"] if not apiKey.strip(): gui.messageBox( # Translators: Message informing the user that no API key is configured. message=_("No API key is configured. Please configure it in settings."), # Translators: Title of the error message. - caption=_("Configuration Error") + caption=_("Configuration Error"), ) return func( dialog.Value, apiKey, - new + new, ) gui.runScriptModalDialog(dialog, callback) diff --git a/addon/locale/uk/LC_MESSAGES/nvda.po b/addon/locale/uk/LC_MESSAGES/nvda.po new file mode 100644 index 0000000..ad84e5e --- /dev/null +++ b/addon/locale/uk/LC_MESSAGES/nvda.po @@ -0,0 +1,202 @@ +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: 'nvda-translations@groups.io'\n" +"POT-Creation-Date: 2026-03-27 09:01+0200\n" +"PO-Revision-Date: 2026-03-28 17:06+0200\n" +"Last-Translator: Volodymyr Pyrih \n" +"Language-Team: \n" +"Language: uk\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 3.9\n" +"X-Poedit-Basepath: .\n" + +#. Translators: Title of the dialog box. +#: addon\globalPlugins\askOpenRouter\dialogs.py:54 +msgid "Chat Manager" +msgstr "Менеджер чату" + +#. Translators: Prompt label to create a new chat. +#: addon\globalPlugins\askOpenRouter\dialogs.py:69 +msgid "C&reate a New Chat" +msgstr "С&творити новий чат" + +#. Translators: Prompt label to continue an existing chat. +#: addon\globalPlugins\askOpenRouter\dialogs.py:75 +msgid "Co&ntinue a Chat" +msgstr "&Продовжити чат" + +#. Translators: Label of the closing button. +#: addon\globalPlugins\askOpenRouter\dialogs.py:81 +msgid "&Close" +msgstr "&Закрити" + +#. Translators: Title of the dialog box to start a new chat. +#: addon\globalPlugins\askOpenRouter\dialogs.py:118 +msgid "New Chat" +msgstr "Новий чат" + +#. Translators: Title of the dialog box to continue an existing chat. +#: addon\globalPlugins\askOpenRouter\dialogs.py:128 +msgid "Continue Chat" +msgstr "Продовжити чат" + +#. Translators: Label of the field that must contain the OpenRouter API key. +#: addon\globalPlugins\askOpenRouter\dialogs.py:167 +msgid "OpenRouter API Key:" +msgstr "Ключ API OpenRouter:" + +#. Translators: Label of the checkbox to display the OpenRouter API key. +#: addon\globalPlugins\askOpenRouter\dialogs.py:188 +msgid "Show API key" +msgstr "Показати ключ API" + +#. Translators: Label of the checkbox to display chat history. +#: addon\globalPlugins\askOpenRouter\dialogs.py:204 +msgid "Display the full chat history for continuous discussions" +msgstr "Показувати всю історію чату для безперервної розмови" + +#. Translators: +#. Translators: Label of the checkbox to show the list of models, including paid ones. +#: addon\globalPlugins\askOpenRouter\dialogs.py:220 +msgid "Use all models, including paid ones." +msgstr "Використовувати всі моделі, включаючи платні." + +#. Translators: Message informing that no free model is available. +#: addon\globalPlugins\askOpenRouter\functions.py:201 +msgid "No free model currently available." +msgstr "Зараз немає безкоштовної моделі." + +#. Translators: Message announcing what the user said. +#: addon\globalPlugins\askOpenRouter\functions.py:332 +msgid "You said:" +msgstr "Ви сказали:" + +#. Translators: Message announcing what the model responded. +#: addon\globalPlugins\askOpenRouter\functions.py:334 +msgid "Model replied:" +msgstr "Відповідь моделі:" + +#. Translators: Message informing that no free models are available. +#: addon\globalPlugins\askOpenRouter\functions.py:406 +#: addon\globalPlugins\askOpenRouter\functions.py:424 +msgid "No free model available at the moment." +msgstr "Наразі безкоштовна модель недоступна." + +#. Translators: Title of the error message. +#: addon\globalPlugins\askOpenRouter\functions.py:408 +#: addon\globalPlugins\askOpenRouter\functions.py:426 +msgid "Model Error" +msgstr "Помилка моделі" + +#. Translators: Title of the HTTP error message. +#: addon\globalPlugins\askOpenRouter\functions.py:465 +#: addon\globalPlugins\askOpenRouter\functions.py:486 +msgid "HTTP Error" +msgstr "Помилка HTTP" + +#. Translators: Network error message. +#: addon\globalPlugins\askOpenRouter\functions.py:493 +msgid "Network error:" +msgstr "Помилка мережі:" + +#. Translators: Message informing that no free models are available at the moment. +#: addon\globalPlugins\askOpenRouter\functions.py:501 +msgid "All free models are currently unavailable. Please try again later." +msgstr "Усі безкоштовні моделі наразі недоступні. Спробуйте пізніше." + +#. Translators: Title of the model unavailable error. +#: addon\globalPlugins\askOpenRouter\functions.py:503 +msgid "Model Unavailable" +msgstr "Модель недоступна" + +#. Translators: Title of the model response message. +#: addon\globalPlugins\askOpenRouter\functions.py:524 +msgid "Model Response" +msgstr "Відповідь моделі" + +#. Translators: Message inviting the user to enter his question. +#: addon\globalPlugins\askOpenRouter\functions.py:556 +msgid "Please enter the question you want to ask OpenRouter" +msgstr "Будь ласка, введіть запитання, яке хочете поставити OpenRouter" + +#. Translators: Message informing the user that the field is empty, he must fill it in. +#: addon\globalPlugins\askOpenRouter\functions.py:566 +msgid "You did not enter anything. Please try again." +msgstr "Ви нічого не ввели. Спробуйте ще раз." + +#. Translators: Title of the error message. +#: addon\globalPlugins\askOpenRouter\functions.py:568 +msgid "Input Error" +msgstr "Помилка введення" + +#. Translators: Message informing the user that no API key is configured. +#: addon\globalPlugins\askOpenRouter\functions.py:577 +msgid "No API key is configured. Please configure it in settings." +msgstr "Ключ API не налаштовано. Будь ласка, зробіть це в налаштуваннях." + +#. Translators: Title of the error message. +#: addon\globalPlugins\askOpenRouter\functions.py:579 +msgid "Configuration Error" +msgstr "Помилка конфігурації" + +#. Translators: Description of the script which opens the interaction dialog box with OpenRouter. +#: addon\globalPlugins\askOpenRouter\__init__.py:57 +msgid "Opens a dialog allowing the user to ask questions to OpenRouter" +msgstr "Відкриває діалог, у якому можна ставити запитання OpenRouter" + +#. Translators: Description of the script which allows to create a new chat. +#: addon\globalPlugins\askOpenRouter\__init__.py:65 +msgid "Opens the prompt to start a new OpenRouter chat." +msgstr "Відкриває підказку для початку нового чату в OpenRouter." + +#. Translators: Description of the script which allows to continue an existing chat. +#: addon\globalPlugins\askOpenRouter\__init__.py:73 +msgid "Opens the prompt to continue an existing OpenRouter chat." +msgstr "Відкриває запит на продовження наявного чату OpenRouter." + +#. Translators: Description of the script which allows to show OpenRouter settings panel.. +#: addon\globalPlugins\askOpenRouter\__init__.py:81 +msgid "Opens the add-on settings panel." +msgstr "Відкриває панель налаштувань додатка." + +#. Add-on summary/title, usually the user visible name of the add-on +#. Translators: Summary/title for this add-on +#. to be shown on installation and add-on information found in add-on store +#: buildVars.py:21 +msgid "Ask OpenRouter" +msgstr "Запитати OpenRouter" + +#. Add-on description +#. Translators: Long description to be shown for this add-on on add-on information from add-on store +#: buildVars.py:24 +msgid "" +"This add-on allows you to ask questions to Artificial Intelligence models " +"available on the OpenRouter platform.\n" +"You can either use automatic random selection of free models or choose a " +"specific model manually, including paid ones.\n" +"To use this add-on, you must create an account on https://openrouter.ai and " +"generate an API key.\n" +"Once configured, you can open a dialog that lets you start a new " +"conversation or continue an existing one." +msgstr "" +"Цей додаток дозволяє ставити запитання моделям штучного інтелекту, " +"доступним на платформі OpenRouter.\n" +"Ви можете скористатися автоматичним випадковим вибором безкоштовних моделей " +"або вибрати конкретну модель вручну, в тому числі платну.\n" +"Щоб використовувати цей додаток, ви повинні створити обліковий запис на " +"https://openrouter.ai та створити ключ API.\n" +"Після налаштування можна відкрити діалог, за допомогою якого можна почати " +"нову розмову або продовжити наявну." + +#. Brief changelog for this version +#. Translators: what's new content for the add-on version to be shown in the add-on store +#: buildVars.py:32 +msgid "" +"Changes for 20260301.0.0\n" +"Updated documentation." +msgstr "" +"Зміни для 20260301.0.0\n" +"Оновлена документація." diff --git a/pyproject.toml b/pyproject.toml index 40acb8f..7de2cc1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,10 +32,11 @@ dependencies = [ "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", # Lint - "uv==0.11.6", + "uv==0.11.15", "ruff==0.14.5", "pre-commit==4.2.0", - "pyright[nodejs]==1.1.407", + "pyright[nodejs]==1.1.411", + "wxpython>=4.2.5", ] [project.urls] Repository = "https://github.com/abdel792/askOpenRouter" diff --git a/uv.lock b/uv.lock index 7e17b67..c6f2a17 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,7 @@ revision = 3 requires-python = "==3.13.*" [[package]] -name = "addontemplate" +name = "askopenrouter" source = { editable = "." } dependencies = [ { name = "crowdin-api-client" }, @@ -18,6 +18,7 @@ dependencies = [ { name = "ruff" }, { name = "scons" }, { name = "uv" }, + { name = "wxpython" }, ] [package.metadata] @@ -30,19 +31,20 @@ requires-dist = [ { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, { name = "pre-commit", specifier = "==4.2.0" }, - { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.411" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, - { name = "uv", specifier = "==0.11.6" }, + { name = "uv", specifier = "==0.11.15" }, + { name = "wxpython", specifier = ">=4.2.5" }, ] [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +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/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { 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]] @@ -106,20 +108,20 @@ wheels = [ [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, + { url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" }, ] [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/f9/f38573ed5844586db374d085911740a501ccfa373b455fc9413f09f85237/filelock-3.29.1.tar.gz", hash = "sha256:d97e6b1b9757569626c58caa07dc4beb1613f4a2938b1e8cc81afca398906c9e", size = 59335, upload-time = "2026-06-03T15:19:04.053Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a0/614c5fe402fd88951df45f4dda2fa3b4e17a99ecd92340771929169b3b95/filelock-3.29.1-py3-none-any.whl", hash = "sha256:85199dfd706869641b72b2e8955d5416a4b2b7dc4b0e8e6d97b4cc1299a6983b", size = 40750, upload-time = "2026-06-03T15:19:02.959Z" }, ] [[package]] @@ -133,11 +135,11 @@ wheels = [ [[package]] name = "idna" -version = "3.13" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +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/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, + { 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]] @@ -245,27 +247,27 @@ wheels = [ [[package]] name = "nodejs-wheel-binaries" -version = "24.15.0" +version = "24.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3f/70/a1e4f4d5986768ab90cc860b1cc3660fd2ded74ca175a900a5c29f839c7d/nodejs_wheel_binaries-24.15.0.tar.gz", hash = "sha256:b43f5c4f6e5768d8845b2ae4682eb703a19bf7aadc84187e2d903ed3a611c859", size = 8057, upload-time = "2026-04-19T15:48:16.899Z" } +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/85/66/54051d14853d6ab4fb85f8be9b042b530be653357fb9a19557498bc91ab7/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:a6232fa8b754220941f52388c8ead923f7c1c7fdf0ea0d98f657523bd9a81ef4", size = 55173485, upload-time = "2026-04-19T15:47:34.561Z" }, - { url = "https://files.pythonhosted.org/packages/ad/5f/66acada164da5ca10a0824db021aa7394ae18396c550cd9280e839a43126/nodejs_wheel_binaries-24.15.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:001a6b62c69d9109c1738163cca00608dd2722e8663af59300054ea02610972d", size = 55348100, upload-time = "2026-04-19T15:47:40.521Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2d/0cbd5ff40c9bb030ca1735d8f8793bd74f08a4cbd49100a1d19313ea57ab/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0fbc48765e60ed0ff30d43898dbf5cadbadf2e5f1e7f204afc2b01493b7ebce6", size = 59668206, upload-time = "2026-04-19T15:47:46.848Z" }, - { url = "https://files.pythonhosted.org/packages/da/d5/91ac63951ec75927a486b83b8cafe650e360fa70ac01dc94adfb32b93b97/nodejs_wheel_binaries-24.15.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20ee0536809795da8a4942fc1ab4cbdebbcaaf29383eab67ba8874268fb00008", size = 60206736, upload-time = "2026-04-19T15:47:52.668Z" }, - { url = "https://files.pythonhosted.org/packages/db/72/dc22776974d928869c0c30d23ee98ed7df254243c2df68f09f5963e8e8b8/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1fade6c214285e72472ca40a631e98ff36559671cd5eefc8bf009471d67f04b4", size = 61720456, upload-time = "2026-04-19T15:47:58.325Z" }, - { url = "https://files.pythonhosted.org/packages/01/0a/34461b9050cb45ee371dccdefc622aef6351506ea2691b08fc761ca67150/nodejs_wheel_binaries-24.15.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3984cb8d87766567aee67a49743227ab40ede6f47734ec990ff90e50b74e7740", size = 62326172, upload-time = "2026-04-19T15:48:04.094Z" }, - { url = "https://files.pythonhosted.org/packages/c9/17/09252bf35672dba926649d59dfe51443a0f6955ad13784e91131d5ec82a2/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_amd64.whl", hash = "sha256:a437601956b532dcb3082046e6978e622733f90edc0932cbb9adb3bb97a16501", size = 41543461, upload-time = "2026-04-19T15:48:09.332Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7e/b649777d148e1e0c2ce349156603cdb12f7ed99921b95d93717393650193/nodejs_wheel_binaries-24.15.0-py2.py3-none-win_arm64.whl", hash = "sha256:bdf4a431e08321a32efc604111c6f23941f87055d796a537e8c4110daecad23f", size = 39233248, upload-time = "2026-04-19T15:48:13.326Z" }, + { 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 = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -286,15 +288,15 @@ wheels = [ [[package]] name = "pyright" -version = "1.1.407" +version = "1.1.411" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "nodeenv" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" }, ] [package.optional-dependencies] @@ -304,15 +306,15 @@ nodejs = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -335,7 +337,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -343,9 +345,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +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/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, + { 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]] @@ -394,42 +396,42 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +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/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { 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.6" +version = "0.11.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dd/f3/8aceeab67ea69805293ab290e7ca8cc1b61a064d28b8a35c76d8eba063dd/uv-0.11.6.tar.gz", hash = "sha256:e3b21b7e80024c95ff339fcd147ac6fc3dd98d3613c9d45d3a1f4fd1057f127b", size = 4073298, upload-time = "2026-04-09T12:09:01.738Z" } +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/1f/fe/4b61a3d5ad9d02e8a4405026ccd43593d7044598e0fa47d892d4dafe44c9/uv-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:ada04dcf89ddea5b69d27ac9cdc5ef575a82f90a209a1392e930de504b2321d6", size = 23780079, upload-time = "2026-04-09T12:08:56.609Z" }, - { url = "https://files.pythonhosted.org/packages/52/db/d27519a9e1a5ffee9d71af1a811ad0e19ce7ab9ae815453bef39dd479389/uv-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5be013888420f96879c6e0d3081e7bcf51b539b034a01777041934457dfbedf3", size = 23214721, upload-time = "2026-04-09T12:09:32.228Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8f/4399fa8b882bd7e0efffc829f73ab24d117d490a93e6bc7104a50282b854/uv-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ffa5dc1cbb52bdce3b8447e83d1601a57ad4da6b523d77d4b47366db8b1ceb18", size = 21750109, upload-time = "2026-04-09T12:09:24.357Z" }, - { url = "https://files.pythonhosted.org/packages/32/07/5a12944c31c3dda253632da7a363edddb869ed47839d4d92a2dc5f546c93/uv-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:bfb107b4dade1d2c9e572992b06992d51dd5f2136eb8ceee9e62dd124289e825", size = 23551146, upload-time = "2026-04-09T12:09:10.439Z" }, - { url = "https://files.pythonhosted.org/packages/79/5b/2ec8b0af80acd1016ed596baf205ddc77b19ece288473b01926c4a9cf6db/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:9e2fe7ce12161d8016b7deb1eaad7905a76ff7afec13383333ca75e0c4b5425d", size = 23331192, upload-time = "2026-04-09T12:09:34.792Z" }, - { url = "https://files.pythonhosted.org/packages/62/7d/eea35935f2112b21c296a3e42645f3e4b1aa8bcd34dcf13345fbd55134b7/uv-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7ed9c6f70c25e8dfeedddf4eddaf14d353f5e6b0eb43da9a14d3a1033d51d915", size = 23337686, upload-time = "2026-04-09T12:09:18.522Z" }, - { url = "https://files.pythonhosted.org/packages/21/47/2584f5ab618f6ebe9bdefb2f765f2ca8540e9d739667606a916b35449eec/uv-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d68a013e609cebf82077cbeeb0809ed5e205257814273bfd31e02fc0353bbfc2", size = 25008139, upload-time = "2026-04-09T12:09:03.983Z" }, - { url = "https://files.pythonhosted.org/packages/95/81/497ae5c1d36355b56b97dc59f550c7e89d0291c163a3f203c6f341dff195/uv-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:93f736dddca03dae732c6fdea177328d3bc4bf137c75248f3d433c57416a4311", size = 25712458, upload-time = "2026-04-09T12:09:07.598Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/74083238e4fab2672b63575b9008f1ea418b02a714bcfcf017f4f6a309b6/uv-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e96a66abe53fced0e3389008b8d2eff8278cfa8bb545d75631ae8ceb9c929aba", size = 24915507, upload-time = "2026-04-09T12:08:50.892Z" }, - { url = "https://files.pythonhosted.org/packages/5a/ee/e14fe10ba455a823ed18233f12de6699a601890905420b5c504abf115116/uv-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b096311b2743b228df911a19532b3f18fa420bf9530547aecd6a8e04bbfaccd", size = 24971011, upload-time = "2026-04-09T12:08:54.016Z" }, - { url = "https://files.pythonhosted.org/packages/3c/a1/7b9c83eaadf98e343317ff6384a7227a4855afd02cdaf9696bcc71ee6155/uv-0.11.6-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:904d537b4a6e798015b4a64ff5622023bd4601b43b6cd1e5f423d63471f5e948", size = 23640234, upload-time = "2026-04-09T12:09:15.735Z" }, - { url = "https://files.pythonhosted.org/packages/d6/51/75ccdd23e76ff1703b70eb82881cd5b4d2a954c9679f8ef7e0136ef2cfab/uv-0.11.6-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:4ed8150c26b5e319381d75ae2ce6aba1e9c65888f4850f4e3b3fa839953c90a5", size = 24452664, upload-time = "2026-04-09T12:09:26.875Z" }, - { url = "https://files.pythonhosted.org/packages/4d/86/ace80fe47d8d48b5e3b5aee0b6eb1a49deaacc2313782870250b3faa36f5/uv-0.11.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c9218c8d4ac35ca6e617fb0951cc0ab2d907c91a6aea2617de0a5494cf162c0", size = 24494599, upload-time = "2026-04-09T12:09:37.368Z" }, - { url = "https://files.pythonhosted.org/packages/05/2d/4b642669b56648194f026de79bc992cbfc3ac2318b0a8d435f3c284934e8/uv-0.11.6-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9e211c83cc890c569b86a4183fcf5f8b6f0c7adc33a839b699a98d30f1310d3a", size = 24159150, upload-time = "2026-04-09T12:09:13.17Z" }, - { url = "https://files.pythonhosted.org/packages/ae/24/7eecd76fe983a74fed1fc700a14882e70c4e857f1d562a9f2303d4286c12/uv-0.11.6-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:d2a1d2089afdf117ad19a4c1dd36b8189c00ae1ad4135d3bfbfced82342595cf", size = 25164324, upload-time = "2026-04-09T12:08:59.56Z" }, - { url = "https://files.pythonhosted.org/packages/27/e0/bbd4ba7c2e5067bbba617d87d306ec146889edaeeaa2081d3e122178ca08/uv-0.11.6-py3-none-win32.whl", hash = "sha256:6e8344f38fa29f85dcfd3e62dc35a700d2448f8e90381077ef393438dcd5012e", size = 22865693, upload-time = "2026-04-09T12:09:21.415Z" }, - { url = "https://files.pythonhosted.org/packages/a5/33/1983ce113c538a856f2d620d16e39691962ecceef091a84086c5785e32e5/uv-0.11.6-py3-none-win_amd64.whl", hash = "sha256:a28bea69c1186303d1200f155c7a28c449f8a4431e458fcf89360cc7ef546e40", size = 25371258, upload-time = "2026-04-09T12:09:40.52Z" }, - { url = "https://files.pythonhosted.org/packages/35/01/be0873f44b9c9bc250fcbf263367fcfc1f59feab996355bcb6b52fff080d/uv-0.11.6-py3-none-win_arm64.whl", hash = "sha256:a78f6d64b9950e24061bc7ec7f15ff8089ad7f5a976e7b65fcadce58fe02f613", size = 23869585, upload-time = "2026-04-09T12:09:29.425Z" }, + { 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 = "virtualenv" -version = "21.2.4" +version = "21.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -437,38 +439,51 @@ dependencies = [ { name = "platformdirs" }, { name = "python-discovery" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, ] [[package]] name = "wrapt" -version = "2.1.2" +version = "2.2.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" } +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/4c/7a/d936840735c828b38d26a854e85d5338894cda544cb7a85a9d5b8b9c4df7/wrapt-2.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787fd6f4d67befa6fe2abdffcbd3de2d82dfc6fb8a6d850407c53332709d030b", size = 61259, upload-time = "2026-03-06T02:53:41.922Z" }, - { url = "https://files.pythonhosted.org/packages/5e/88/9a9b9a90ac8ca11c2fdb6a286cb3a1fc7dd774c00ed70929a6434f6bc634/wrapt-2.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bdf26e03e6d0da3f0e9422fd36bcebf7bc0eeb55fdf9c727a09abc6b9fe472e", size = 61851, upload-time = "2026-03-06T02:52:48.672Z" }, - { url = "https://files.pythonhosted.org/packages/03/a9/5b7d6a16fd6533fed2756900fc8fc923f678179aea62ada6d65c92718c00/wrapt-2.1.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bbac24d879aa22998e87f6b3f481a5216311e7d53c7db87f189a7a0266dafffb", size = 121446, upload-time = "2026-03-06T02:54:14.013Z" }, - { url = "https://files.pythonhosted.org/packages/45/bb/34c443690c847835cfe9f892be78c533d4f32366ad2888972c094a897e39/wrapt-2.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16997dfb9d67addc2e3f41b62a104341e80cac52f91110dece393923c0ebd5ca", size = 123056, upload-time = "2026-03-06T02:54:10.829Z" }, - { url = "https://files.pythonhosted.org/packages/93/b9/ff205f391cb708f67f41ea148545f2b53ff543a7ac293b30d178af4d2271/wrapt-2.1.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:162e4e2ba7542da9027821cb6e7c5e068d64f9a10b5f15512ea28e954893a267", size = 117359, upload-time = "2026-03-06T02:53:03.623Z" }, - { url = "https://files.pythonhosted.org/packages/1f/3d/1ea04d7747825119c3c9a5e0874a40b33594ada92e5649347c457d982805/wrapt-2.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f29c827a8d9936ac320746747a016c4bc66ef639f5cd0d32df24f5eacbf9c69f", size = 121479, upload-time = "2026-03-06T02:53:45.844Z" }, - { url = "https://files.pythonhosted.org/packages/78/cc/ee3a011920c7a023b25e8df26f306b2484a531ab84ca5c96260a73de76c0/wrapt-2.1.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:a9dd9813825f7ecb018c17fd147a01845eb330254dff86d3b5816f20f4d6aaf8", size = 116271, upload-time = "2026-03-06T02:54:46.356Z" }, - { url = "https://files.pythonhosted.org/packages/98/fd/e5ff7ded41b76d802cf1191288473e850d24ba2e39a6ec540f21ae3b57cb/wrapt-2.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f8dbdd3719e534860d6a78526aafc220e0241f981367018c2875178cf83a413", size = 120573, upload-time = "2026-03-06T02:52:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/47/c5/242cae3b5b080cd09bacef0591691ba1879739050cc7c801ff35c8886b66/wrapt-2.1.2-cp313-cp313-win32.whl", hash = "sha256:5c35b5d82b16a3bc6e0a04349b606a0582bc29f573786aebe98e0c159bc48db6", size = 58205, upload-time = "2026-03-06T02:53:47.494Z" }, - { url = "https://files.pythonhosted.org/packages/12/69/c358c61e7a50f290958809b3c61ebe8b3838ea3e070d7aac9814f95a0528/wrapt-2.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:f8bc1c264d8d1cf5b3560a87bbdd31131573eb25f9f9447bb6252b8d4c44a3a1", size = 60452, upload-time = "2026-03-06T02:53:30.038Z" }, - { url = "https://files.pythonhosted.org/packages/8e/66/c8a6fcfe321295fd8c0ab1bd685b5a01462a9b3aa2f597254462fc2bc975/wrapt-2.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:3beb22f674550d5634642c645aba4c72a2c66fb185ae1aebe1e955fae5a13baf", size = 58842, upload-time = "2026-03-06T02:52:52.114Z" }, - { url = "https://files.pythonhosted.org/packages/da/55/9c7052c349106e0b3f17ae8db4b23a691a963c334de7f9dbd60f8f74a831/wrapt-2.1.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0fc04bc8664a8bc4c8e00b37b5355cffca2535209fba1abb09ae2b7c76ddf82b", size = 63075, upload-time = "2026-03-06T02:53:19.108Z" }, - { url = "https://files.pythonhosted.org/packages/09/a8/ce7b4006f7218248dd71b7b2b732d0710845a0e49213b18faef64811ffef/wrapt-2.1.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a9b9d50c9af998875a1482a038eb05755dfd6fe303a313f6a940bb53a83c3f18", size = 63719, upload-time = "2026-03-06T02:54:33.452Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e5/2ca472e80b9e2b7a17f106bb8f9df1db11e62101652ce210f66935c6af67/wrapt-2.1.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2d3ff4f0024dd224290c0eabf0240f1bfc1f26363431505fb1b0283d3b08f11d", size = 152643, upload-time = "2026-03-06T02:52:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/36/42/30f0f2cefca9d9cbf6835f544d825064570203c3e70aa873d8ae12e23791/wrapt-2.1.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3278c471f4468ad544a691b31bb856374fbdefb7fee1a152153e64019379f015", size = 158805, upload-time = "2026-03-06T02:54:25.441Z" }, - { url = "https://files.pythonhosted.org/packages/bb/67/d08672f801f604889dcf58f1a0b424fe3808860ede9e03affc1876b295af/wrapt-2.1.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8914c754d3134a3032601c6984db1c576e6abaf3fc68094bb8ab1379d75ff92", size = 145990, upload-time = "2026-03-06T02:53:57.456Z" }, - { url = "https://files.pythonhosted.org/packages/68/a7/fd371b02e73babec1de6ade596e8cd9691051058cfdadbfd62a5898f3295/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ff95d4264e55839be37bafe1536db2ab2de19da6b65f9244f01f332b5286cfbf", size = 155670, upload-time = "2026-03-06T02:54:55.309Z" }, - { url = "https://files.pythonhosted.org/packages/86/2d/9fe0095dfdb621009f40117dcebf41d7396c2c22dca6eac779f4c007b86c/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:76405518ca4e1b76fbb1b9f686cff93aebae03920cc55ceeec48ff9f719c5f67", size = 144357, upload-time = "2026-03-06T02:54:24.092Z" }, - { url = "https://files.pythonhosted.org/packages/0e/b6/ec7b4a254abbe4cde9fa15c5d2cca4518f6b07d0f1b77d4ee9655e30280e/wrapt-2.1.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c0be8b5a74c5824e9359b53e7e58bef71a729bacc82e16587db1c4ebc91f7c5a", size = 150269, upload-time = "2026-03-06T02:53:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6b/2fabe8ebf148f4ee3c782aae86a795cc68ffe7d432ef550f234025ce0cfa/wrapt-2.1.2-cp313-cp313t-win32.whl", hash = "sha256:f01277d9a5fc1862f26f7626da9cf443bebc0abd2f303f41c5e995b15887dabd", size = 59894, upload-time = "2026-03-06T02:54:15.391Z" }, - { url = "https://files.pythonhosted.org/packages/ca/fb/9ba66fc2dedc936de5f8073c0217b5d4484e966d87723415cc8262c5d9c2/wrapt-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:84ce8f1c2104d2f6daa912b1b5b039f331febfeee74f8042ad4e04992bd95c8f", size = 63197, upload-time = "2026-03-06T02:54:41.943Z" }, - { url = "https://files.pythonhosted.org/packages/c0/1c/012d7423c95d0e337117723eb8ecf73c622ce15a97847e84cf3f8f26cd7e/wrapt-2.1.2-cp313-cp313t-win_arm64.whl", hash = "sha256:a93cd767e37faeddbe07d8fc4212d5cba660af59bdb0f6372c93faaa13e6e679", size = 60363, upload-time = "2026-03-06T02:54:48.093Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" }, + { 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" }, +] + +[[package]] +name = "wxpython" +version = "4.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/43/81657a6b126ffc19163500a8184d683cec08eb4e1d06905cd0c371c702d0/wxpython-4.2.5.tar.gz", hash = "sha256:44e836d1bccd99c38790bb034b6ecf70d9060f6734320560f7c4b0d006144793", size = 58732217, upload-time = "2026-02-08T20:40:42.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/b9/1fee711ad5c26e7bbd4e10fae14b2ce0499684a084c3c60169373496ceae/wxpython-4.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f7ec6b028e8b1c4cad1ecb5c8402c2cae7840a25758be0fc209e56df86d1cac", size = 17775906, upload-time = "2026-02-08T20:40:12.031Z" }, + { url = "https://files.pythonhosted.org/packages/3d/53/521a79cbb169ab6b123e79ea23ebafe3046c1999a431b6fc864f1217bb86/wxpython-4.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:77ac5335d8e4aae92732fc039df24a58181cdfb5bc7931692f1f9415e9eeee7d", size = 18857767, upload-time = "2026-02-08T20:40:14.486Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ea/a69ad0a1e7b01876619982b6cf8db0e26d0f3776b9be73b61d9f0662a2ce/wxpython-4.2.5-cp313-cp313-win32.whl", hash = "sha256:0985f190565b94635f146989886196a7e9faced8911800910460919cb72668cc", size = 14520419, upload-time = "2026-02-08T20:40:17.401Z" }, + { url = "https://files.pythonhosted.org/packages/a8/bd/d2698369dbc43aa5c9324c23fdd5cd3b23c245861e334b1d976209913f90/wxpython-4.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:3fd3649fc4752f1a02776b7057073c932e5229bbab2031762b01532bcc6bd074", size = 16576741, upload-time = "2026-02-08T20:40:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/cb/4e/4181734a2bc05940ba4feb3feb2474416b1dc12c329a2ac164632582c4d6/wxpython-4.2.5-cp313-cp313-win_arm64.whl", hash = "sha256:b794d9912464990ea1fd3744fb73fbd7446149e230e5a611ba40eb4ac74755a1", size = 15537287, upload-time = "2026-02-08T20:40:24.658Z" }, ]