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" },
]