From 28c060dd336b5d6e72b1733111d59f188e53e28e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 10:24:44 +0100 Subject: [PATCH 001/100] Add scripts to allow addons from personal repos to be synchronized with Crowdin --- _l10n/crowdinSync.py | 92 ++++ _l10n/files.json | 1 + _l10n/l10nUtil.py | 978 +++++++++++++++++++++++++++++++++++++ _l10n/markdownTranslate.py | 733 +++++++++++++++++++++++++++ _l10n/md2html.py | 197 ++++++++ 5 files changed, 2001 insertions(+) create mode 100644 _l10n/crowdinSync.py create mode 100644 _l10n/files.json create mode 100644 _l10n/l10nUtil.py create mode 100644 _l10n/markdownTranslate.py create mode 100644 _l10n/md2html.py diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py new file mode 100644 index 0000000..1a56070 --- /dev/null +++ b/_l10n/crowdinSync.py @@ -0,0 +1,92 @@ +# A part of NonVisual Desktop Access (NVDA) +# based on file from https://github.com/jcsteh/osara +# Copyright (C) 2023-2024 NV Access Limited, James Teh +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + + +import argparse +import os + +import requests + +from l10nUtil import getFiles + +AUTH_TOKEN = os.getenv("crowdinAuthToken", "").strip() +if not AUTH_TOKEN: + raise ValueError("crowdinAuthToken environment variable not set") +PROJECT_ID = os.getenv("crowdinProjectID", "").strip() +if not PROJECT_ID: + raise ValueError("crowdinProjectID environment variable not set") + + +def request( + path: str, + method=requests.get, + headers: dict[str, str] | None = None, + **kwargs, +) -> requests.Response: + if headers is None: + headers = {} + headers["Authorization"] = f"Bearer {AUTH_TOKEN}" + r = method( + f"https://api.crowdin.com/api/v2/{path}", + headers=headers, + **kwargs, + ) + # Convert errors to exceptions, but print the response before raising. + try: + r.raise_for_status() + except requests.exceptions.HTTPError: + print(r.json()) + raise + return r + + +def projectRequest(path: str, **kwargs) -> requests.Response: + return request(f"projects/{PROJECT_ID}/{path}", **kwargs) + + +def uploadSourceFile(localFilePath: str) -> None: + files = getFiles() + fn = os.path.basename(localFilePath) + crowdinFileID = files.get(fn) + print(f"Uploading {localFilePath} to Crowdin temporary storage as {fn}") + with open(localFilePath, "rb") as f: + r = request( + "storages", + method=requests.post, + headers={"Crowdin-API-FileName": fn}, + data=f, + ) + storageID = r.json()["data"]["id"] + print(f"Updating file {crowdinFileID} on Crowdin with storage ID {storageID}") + r = projectRequest( + f"files/{crowdinFileID}", + method=requests.put, + json={"storageId": storageID}, + ) + revisionId = r.json()["data"]["revisionId"] + print(f"Updated to revision {revisionId}") + + +def main(): + parser = argparse.ArgumentParser( + description="Syncs translations with Crowdin.", + ) + commands = parser.add_subparsers(dest="command", required=True) + uploadCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + # uploadCommand.add_argument("crowdinFileID", type=int, help="The Crowdin file ID.") + uploadCommand.add_argument("localFilePath", help="The path to the local file.") + args = parser.parse_args() + if args.command == "uploadSourceFile": + uploadSourceFile(args.localFilePath) + else: + raise ValueError(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + main() diff --git a/_l10n/files.json b/_l10n/files.json new file mode 100644 index 0000000..9264714 --- /dev/null +++ b/_l10n/files.json @@ -0,0 +1 @@ +{"emoticons.pot": 176, "emoticons.xliff": 178, "goldwave.pot": 180, "goldwave.xliff": 182, "eMule.pot": 194, "enhancedTouchGestures.pot": 210, "resourceMonitor.pot": 214, "stationPlaylist.pot": 218, "cursorLocator.pot": 224, "pcKbBrl.pot": 228, "readFeeds.pot": 232, "reportSymbols.pot": 236, "urlShortener.pot": 240, "customNotifications.pot": 244, "readonlyProfiles.pot": 248, "enhancedAnnotations.pot": 252, "clipContentsDesigner.pot": 256, "clipContentsDesigner.xliff": 258, "controlUsageAssistant.pot": 260, "controlUsageAssistant.xliff": 262, "eMule.xliff": 264, "enhancedAnnotations.xliff": 266, "customNotifications.xliff": 268, "readonlyProfiles.xliff": 270, "urlShortener.xliff": 272, "reportSymbols.xliff": 274, "pcKbBrl.xliff": 276, "readFeeds.xliff": 278, "stationPlaylist.xliff": 282, "resourceMonitor.xliff": 284, "enhancedTouchGestures.xliff": 286, "rdAccess.pot": 288, "rdAccess.xliff": 290, "winMag.pot": 292, "winMag.xliff": 294, "charInfo.pot": 296, "charInfo.xliff": 298, "BMI.pot": 300, "BMI.xliff": 302, "tonysEnhancements.pot": 304, "tonysEnhancements.xliff": 306, "nvdaDevTestToolbox.pot": 308, "nvdaDevTestToolbox.xliff": 310, "easyTableNavigator.pot": 312, "easyTableNavigator.xliff": 314, "updateChannel.pot": 320, "updateChannel.xliff": 322, "instantTranslate.pot": 324, "instantTranslate.xliff": 326, "unicodeBrailleInput.pot": 328, "unicodeBrailleInput.xliff": 330, "columnsReview.pot": 332, "columnsReview.xliff": 334, "Access8Math.pot": 336, "Access8Math.xliff": 338, "systrayList.pot": 340, "systrayList.xliff": 342, "winWizard.pot": 344, "winWizard.xliff": 346, "speechLogger.pot": 348, "speechLogger.xliff": 350, "sayProductNameAndVersion.pot": 352, "sayProductNameAndVersion.xliff": 354, "objPad.pot": 356, "objPad.xliff": 358, "SentenceNav.pot": 360, "SentenceNav.xliff": 362, "wordNav.pot": 364, "wordNav.xliff": 366, "goldenCursor.pot": 368, "goldenCursor.xliff": 370, "MSEdgeDiscardAnnouncements.pot": 372, "MSEdgeDiscardAnnouncements.xliff": 374, "dayOfTheWeek.pot": 376, "dayOfTheWeek.xliff": 378, "outlookExtended.pot": 380, "outlookExtended.xliff": 382, "proxy.pot": 384, "proxy.xliff": 386, "searchWith.pot": 388, "searchWith.xliff": 390, "sayCurrentKeyboardLanguage.pot": 392, "sayCurrentKeyboardLanguage.xliff": 394, "robEnhancements.pot": 396, "robEnhancements.xliff": 398, "objWatcher.pot": 400, "objWatcher.xliff": 402, "mp3DirectCut.pot": 404, "mp3DirectCut.xliff": 406, "beepKeyboard.pot": 408, "beepKeyboard.xliff": 410, "numpadNavMode.pot": 412, "numpadNavMode.xliff": 414, "dropbox.pot": 416, "dropbox.xliff": 418, "reviewCursorCopier.pot": 420, "reviewCursorCopier.xliff": 422, "inputLock.pot": 424, "inputLock.xliff": 426, "debugHelper.pot": 428, "debugHelper.xliff": 430, "virtualRevision.pot": 432, "virtualRevision.xliff": 434, "cursorLocator.xliff": 436, "evtTracker.pot": 438, "evtTracker.xliff": 440} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py new file mode 100644 index 0000000..00bee4c --- /dev/null +++ b/_l10n/l10nUtil.py @@ -0,0 +1,978 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024-2025 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import crowdin_api as crowdin +import tempfile +import lxml.etree +import os +import shutil +import argparse +import markdownTranslate +import md2html +import requests +import codecs +import re +import subprocess +import sys +import zipfile +import time +import json + +CROWDIN_PROJECT_ID = 780748 +POLLING_INTERVAL_SECONDS = 5 +EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes +JSON_FILE = os.path.join(os.path.dirname(__file__), "files.json") + + +def fetchCrowdinAuthToken() -> str: + """ + Fetch the Crowdin auth token from the ~/.nvda_crowdin file or prompt the user for it. + If provided by the user, the token will be saved to the ~/.nvda_crowdin file. + :return: The auth token + """ + crowdinAuthToken = os.getenv("crowdinAuthToken", "") + if crowdinAuthToken: + print("Using Crowdin auth token from environment variable.") + return crowdinAuthToken + token_path = os.path.expanduser("~/.nvda_crowdin") + if os.path.exists(token_path): + with open(token_path, "r") as f: + token = f.read().strip() + print("Using auth token from ~/.nvda_crowdin") + return token + print("A Crowdin auth token is required to proceed.") + print("Please visit https://crowdin.com/settings#api-key") + print("Create a personal access token with translations permissions, and enter it below.") + token = input("Enter Crowdin auth token: ").strip() + with open(token_path, "w") as f: + f.write(token) + return token + + +_crowdinClient = None + + +def getCrowdinClient() -> crowdin.CrowdinClient: + """ + Create or fetch the Crowdin client instance. + :return: The Crowdin client + """ + global _crowdinClient + if _crowdinClient is None: + token = fetchCrowdinAuthToken() + _crowdinClient = crowdin.CrowdinClient(project_id=CROWDIN_PROJECT_ID, token=token) + return _crowdinClient + + +def fetchLanguageFromXliff(xliffPath: str, source: bool = False) -> str: + """ + Fetch the language from an xliff file. + This function also prints a message to the console stating the detected language if found, or a warning if not found. + :param xliffPath: Path to the xliff file + :param source: If True, fetch the source language, otherwise fetch the target language + :return: The language code + """ + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + lang = xliffRoot.get("srcLang" if source else "trgLang") + if lang is None: + print(f"Could not detect language for xliff file {xliffPath}, {source=}") + else: + print(f"Detected language {lang} for xliff file {xliffPath}, {source=}") + return lang + + +def preprocessXliff(xliffPath: str, outputPath: str): + """ + Replace corrupt or empty translated segment targets with the source text, + marking the segment again as "initial" state. + This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. + :param xliffPath: Path to the xliff file to be processed + :param outputPath: Path to the resulting xliff file + """ + print(f"Preprocessing xliff file at {xliffPath}") + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + file = xliffRoot.find("./xliff:file", namespaces=namespace) + units = file.findall("./xliff:unit", namespaces=namespace) + segmentCount = 0 + emptyTargetCount = 0 + corruptTargetcount = 0 + for unit in units: + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + print("Warning: No segment element in unit") + continue + source = segment.find("./xliff:source", namespaces=namespace) + if source is None: + print("Warning: No source element in segment") + continue + sourceText = source.text + segmentCount += 1 + target = segment.find("./xliff:target", namespaces=namespace) + if target is None: + continue + targetText = target.text + # Correct empty targets + if not targetText: + emptyTargetCount += 1 + target.text = sourceText + segment.set("state", "initial") + # Correct corrupt target tags + elif targetText in ( + "", + "<target/>", + "", + "<target></target>", + ): + corruptTargetcount += 1 + target.text = sourceText + segment.set("state", "initial") + xliff.write(outputPath, encoding="utf-8") + print( + f"Processed {segmentCount} segments, removing {emptyTargetCount} empty targets, {corruptTargetcount} corrupt targets", + ) + + +def stripXliff(xliffPath: str, outputPath: str, oldXliffPath: str | None = None): + """ + Removes notes and skeleton elements from an xliff file before upload to Crowdin. + Removes empty and corrupt translations. + Removes untranslated segments. + Removes existing translations if an old xliff file is provided. + This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. + :param xliffPath: Path to the xliff file to be stripped + :param outputPath: Path to the resulting xliff file + :param oldXliffPath: Path to the old xliff file containing existing translations that should be also stripped. + """ + print(f"Creating stripped xliff at {outputPath} from {xliffPath}") + namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} + xliff = lxml.etree.parse(xliffPath) + xliffRoot = xliff.getroot() + if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {xliffPath}") + oldXliffRoot = None + if oldXliffPath: + oldXliff = lxml.etree.parse(oldXliffPath) + oldXliffRoot = oldXliff.getroot() + if oldXliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": + raise ValueError(f"Not an xliff file: {oldXliffPath}") + skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) + if skeletonNode is not None: + skeletonNode.getparent().remove(skeletonNode) + file = xliffRoot.find("./xliff:file", namespaces=namespace) + units = file.findall("./xliff:unit", namespaces=namespace) + segmentCount = 0 + untranslatedCount = 0 + emptyCount = 0 + corruptCount = 0 + existingTranslationCount = 0 + for unit in units: + unitID = unit.get("id") + notes = unit.find("./xliff:notes", namespaces=namespace) + if notes is not None: + unit.remove(notes) + segment = unit.find("./xliff:segment", namespaces=namespace) + if segment is None: + print("Warning: No segment element in unit") + continue + segmentCount += 1 + state = segment.get("state") + if state == "initial": + file.remove(unit) + untranslatedCount += 1 + continue + target = segment.find("./xliff:target", namespaces=namespace) + if target is None: + file.remove(unit) + untranslatedCount += 1 + continue + targetText = target.text + if not targetText: + emptyCount += 1 + file.remove(unit) + continue + elif targetText in ( + "", + "<target/>", + "", + "<target></target>", + ): + corruptCount += 1 + file.remove(unit) + continue + if oldXliffRoot: + # Remove existing translations + oldTarget = oldXliffRoot.find( + f"./xliff:file/xliff:unit[@id='{unitID}']/xliff:segment/xliff:target", + namespaces=namespace, + ) + if oldTarget is not None and oldTarget.getparent().get("state") != "initial": + if oldTarget.text == targetText: + file.remove(unit) + existingTranslationCount += 1 + xliff.write(outputPath, encoding="utf-8") + if corruptCount > 0: + print(f"Removed {corruptCount} corrupt translations.") + if emptyCount > 0: + print(f"Removed {emptyCount} empty translations.") + if existingTranslationCount > 0: + print(f"Ignored {existingTranslationCount} existing translations.") + keptTranslations = segmentCount - untranslatedCount - emptyCount - corruptCount - existingTranslationCount + print(f"Added or changed {keptTranslations} translations.") + + +def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): + """ + Download a translation file from Crowdin. + :param crowdinFilePath: The Crowdin file path + :param localFilePath: The path to save the local file + :param language: The language code to download the translation for + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(crowdinFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(crowdinFilePath) + print(f"Requesting export of {crowdinFilePath} for {language} from Crowdin") + res = getCrowdinClient().translations.export_project_translation( + fileIds=[fileId], + targetLanguageId=language, + ) + if res is None: + raise ValueError("Crowdin export failed") + download_url = res["data"]["url"] + print(f"Downloading from {download_url}") + with open(localFilePath, "wb") as f: + r = requests.get(download_url) + f.write(r.content) + print(f"Saved to {localFilePath}") + + +def uploadSourceFile(localFilePath: str): + """ + Upload a source file to Crowdin. + :param localFilePath: The path to the local file to be uploaded + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(localFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(localFilePath) + res = getCrowdinClient().storages.add_storage( + open(localFilePath, "rb"), + ) + if res is None: + raise ValueError("Crowdin storage upload failed") + storageId = res["data"]["id"] + print(f"Stored with ID {storageId}") + filename = os.path.basename(localFilePath) + fileId = files.get(filename) + print(f"File ID: {fileId}") + match fileId: + case None: + if os.path.splitext(filename)[1] == ".pot": + title=f"{os.path.splitext(filename)[0]} interface" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + else: + title=f"{os.path.splitext(filename)[0]} documentation" + exportPattern =f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern + } + print(f"Importing source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file(storageId=storageId, projectId=CROWDIN_PROJECT_ID, name=filename, title=title, exportOptions=exportOptions) + print("Done") + case _: + res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) + + +def getFiles() -> dict: + """Gets files from Crowdin, and write them to a json file.""" + + res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, limit=500) + if res is None: + raise ValueError("Getting files from Crowdin failed") + dictionary = dict() + data = res["data"] + for file in data: + fileInfo = file["data"] + name = fileInfo["name"] + id = fileInfo["id"] + dictionary[name] = id + with open(JSON_FILE, "w", encoding="utf-8") as jsonFile: + json.dump(dictionary, jsonFile, ensure_ascii=False) + return dictionary + + +def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): + """ + Upload a translation file to Crowdin. + :param crowdinFilePath: The Crowdin file path + :param localFilePath: The path to the local file to be uploaded + :param language: The language code to upload the translation for + """ + with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(crowdinFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(crowdinFilePath) + print(f"Uploading {localFilePath} to Crowdin") + res = getCrowdinClient().storages.add_storage( + open(localFilePath, "rb"), + ) + if res is None: + raise ValueError("Crowdin storage upload failed") + storageId = res["data"]["id"] + print(f"Stored with ID {storageId}") + print(f"Importing translation for {crowdinFilePath} in {language} from storage with ID {storageId}") + res = getCrowdinClient().translations.upload_translation( + fileId=fileId, + languageId=language, + storageId=storageId, + autoApproveImported=True, + importEqSuggestions=True, + ) + print("Done") + + +def exportTranslations(outputDir: str, language: str | None = None): + """ + Export translation files from Crowdin as a bundle. + :param outputDir: Directory to save translation files. + :param language: The language code to export (e.g., 'es', 'fr', 'de'). + If None, exports all languages. + """ + + # Create output directory if it doesn't exist + os.makedirs(outputDir, exist_ok=True) + + client = getCrowdinClient() + + requestData = { + "skipUntranslatedStrings": False, + "skipUntranslatedFiles": True, + "exportApprovedOnly": False, + } + + if language is not None: + requestData["targetLanguageIds"] = [language] + + if language is None: + print("Requesting export of all translations from Crowdin...") + else: + print(f"Requesting export of all translations for language: {language}") + build_res = client.translations.build_project_translation(request_data=requestData) + + if language is None: + zip_filename = "translations.zip" + else: + zip_filename = f"translations_{language}.zip" + + if build_res is None: + raise ValueError("Failed to start translation build") + + build_id = build_res["data"]["id"] + print(f"Build started with ID: {build_id}") + + # Wait for the build to complete + print("Waiting for build to complete...") + while True: + status_res = client.translations.check_project_build_status(build_id) + if status_res is None: + raise ValueError("Failed to check build status") + + status = status_res["data"]["status"] + progress = status_res["data"]["progress"] + print(f"Build status: {status} ({progress}%)") + + if status == "finished": + break + elif status == "failed": + raise ValueError("Translation build failed") + + time.sleep(POLLING_INTERVAL_SECONDS) + + # Download the completed build + print("Downloading translations archive...") + download_res = client.translations.download_project_translations(build_id) + if download_res is None: + raise ValueError("Failed to get download URL") + + download_url = download_res["data"]["url"] + print(f"Downloading from {download_url}") + + # Download and extract the ZIP file + zip_path = os.path.join(outputDir, zip_filename) + response = requests.get(download_url, stream=True, timeout=EXPORT_TIMEOUT_SECONDS) + response.raise_for_status() + + with open(zip_path, "wb") as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + print(f"Archive saved to {zip_path}") + print("Extracting translations...") + + with zipfile.ZipFile(zip_path, "r") as zip_ref: + zip_ref.extractall(outputDir) + + # Remove the zip file + os.remove(zip_path) + + if language is None: + print(f"\nExport complete! All translations extracted to '{outputDir}' directory.") + else: + print(f"\nExport complete! All {language} translations extracted to '{outputDir}' directory.") + + +class _PoChecker: + """Checks a po file for errors not detected by msgfmt. + This first runs msgfmt to check for syntax errors. + It then checks for mismatched Python percent and brace interpolations. + Construct an instance and call the L{check} method. + """ + + FUZZY = "#, fuzzy" + MSGID = "msgid" + MSGID_PLURAL = "msgid_plural" + MSGSTR = "msgstr" + + def __init__(self, po: str): + """Constructor. + :param po: The path to the po file to check. + """ + self._poPath = po + with codecs.open(po, "r", "utf-8") as file: + self._poContent = file.readlines() + self._string: str | None = None + + self.alerts: list[str] = [] + """List of error and warning messages found in the po file.""" + + self.hasSyntaxError: bool = False + """Whether there is a syntax error in the po file.""" + + self.warningCount: int = 0 + """Number of warnings found.""" + + self.errorCount: int = 0 + """Number of errors found.""" + + def _addToString(self, line: list[str], startingCommand: str | None = None) -> None: + """Helper function to add a line to the current string. + :param line: The line to add. + :param startingCommand: The command that started this string, if any. + This is used to determine whether to strip the command and quotes. + """ + if startingCommand: + # Strip the command and the quotes. + self._string = line[len(startingCommand) + 2 : -1] + else: + # Strip the quotes. + self._string += line[1:-1] + + def _finishString(self) -> str: + """Helper function to finish the current string. + :return: The finished string. + """ + string = self._string + self._string = None + return string + + def _messageAlert(self, alert: str, isError: bool = True) -> None: + """Helper function to add an alert about a message. + :param alert: The alert message. + :param isError: Whether this is an error or a warning. + """ + if self._fuzzy: + # Fuzzy messages don't get used, so this shouldn't be considered an error. + isError = False + if isError: + self.errorCount += 1 + else: + self.warningCount += 1 + if self._fuzzy: + msgType = "Fuzzy message" + else: + msgType = "Message" + self.alerts.append( + f"{msgType} starting on line {self._messageLineNum}\n" + f'Original: "{self._msgid}"\n' + f'Translated: "{self._msgstr[-1]}"\n' + f"{'ERROR' if isError else 'WARNING'}: {alert}", + ) + + @property + def MSGFMT_PATH(self) -> str: + try: + # When running from source, miscDeps is the sibling of parent this script. + _MSGFMT = os.path.join(os.path.dirname(__file__), "..", "miscDeps", "tools", "msgfmt.exe") + except NameError: + # When running from a frozen executable, __file__ is not defined. + # In this case, we use the distribution path. + # When running from a distribution, source/l10nUtil.py is built to l10nUtil.exe. + # miscDeps is the sibling of this script in the distribution. + _MSGFMT = os.path.join(sys.prefix, "miscDeps", "tools", "msgfmt.exe") + + if not os.path.exists(_MSGFMT): + raise FileNotFoundError( + "msgfmt executable not found. " + "Please ensure that miscDeps/tools/msgfmt.exe exists in the source tree or distribution.", + ) + return _MSGFMT + + def _checkSyntax(self) -> None: + """Check the syntax of the po file using msgfmt. + This will set the hasSyntaxError attribute to True if there is a syntax error. + """ + + result = subprocess.run( + (self.MSGFMT_PATH, "-o", "-", self._poPath), + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, # Ensures stderr is a text stream + ) + if result.returncode != 0: + output = result.stderr.rstrip().replace("\r\n", "\n") + self.alerts.append(output) + self.hasSyntaxError = True + self.errorCount = 1 + + def _checkMessages(self) -> None: + command = None + self._msgid = None + self._msgid_plural = None + self._msgstr = None + nextFuzzy = False + self._fuzzy = False + for lineNum, line in enumerate(self._poContent, 1): + line = line.strip() + if line.startswith(self.FUZZY): + nextFuzzy = True + continue + elif line.startswith(self.MSGID) and not line.startswith(self.MSGID_PLURAL): + # New message. + if self._msgstr is not None: + self._msgstr[-1] = self._finishString() + # Check the message we just handled. + self._checkMessage() + command = self.MSGID + start = command + self._messageLineNum = lineNum + self._fuzzy = nextFuzzy + nextFuzzy = False + elif line.startswith(self.MSGID_PLURAL): + self._msgid = self._finishString() + command = self.MSGID_PLURAL + start = command + elif line.startswith(self.MSGSTR): + self._handleMsgStrReaching(lastCommand=command) + command = self.MSGSTR + start = line[: line.find(" ")] + elif line.startswith('"'): + # Continuing a string. + start = None + else: + # This line isn't of interest. + continue + self._addToString(line, startingCommand=start) + if command == self.MSGSTR: + # Handle the last message. + self._msgstr[-1] = self._finishString() + self._checkMessage() + + def _handleMsgStrReaching(self, lastCommand: str) -> None: + """Helper function used by _checkMessages to handle the required processing when reaching a line + starting with "msgstr". + :param lastCommand: the current command just before the msgstr line is reached. + """ + + # Finish the string of the last command and check the message if it was an msgstr + if lastCommand == self.MSGID: + self._msgid = self._finishString() + elif lastCommand == self.MSGID_PLURAL: + self._msgid_plural = self._finishString() + elif lastCommand == self.MSGSTR: + self._msgstr[-1] = self._finishString() + self._checkMessage() + else: + raise RuntimeError(f"Unexpected command before line {self._messageLineNum}: {lastCommand}") + + # For first msgstr create the msgstr list + if lastCommand != self.MSGSTR: + self._msgstr = [] + + # Initiate the string for the current msgstr + self._msgstr.append("") + + def check(self) -> bool: + """Check the file. + Once this returns, you can call getReport to obtain a report. + This method should not be called more than once. + :return: True if the file is okay, False if there were problems. + """ + self._checkSyntax() + if self.alerts: + return False + self._checkMessages() + if self.alerts: + return False + return True + + # e.g. %s %d %10.2f %-5s (but not %%) or %%(name)s %(name)d + RE_UNNAMED_PERCENT = re.compile( + # Does not include optional mapping key, as that's handled by a different regex + r""" + (?:(?<=%%)|(? tuple[list[str], set[str], set[str]]: + """Get the percent and brace interpolations in a string. + :param text: The text to check. + :return: A tuple of a list and two sets: + - unnamed percent interpolations (e.g. %s, %d) + - named percent interpolations (e.g. %(name)s) + - brace format interpolations (e.g. {name}, {name:format}) + """ + unnamedPercent = self.RE_UNNAMED_PERCENT.findall(text) + namedPercent = set(self.RE_NAMED_PERCENT.findall(text)) + formats = set() + for m in self.RE_FORMAT.finditer(text): + if not m.group(1): + self._messageAlert( + "Unspecified positional argument in brace format", + # Skip as error as many of these had been introduced in the source .po files. + # These should be fixed in the source .po files to add names to instances of "{}". + # This causes issues where the order of the arguments change in the string. + # e.g. "Character: {}\nReplacement: {}" being translated to "Replacement: {}\nCharacter: {}" + # will result in the expected interpolation being in the wrong place. + # This should be changed isError=True. + isError=False, + ) + formats.add(m.group(0)) + return unnamedPercent, namedPercent, formats + + def _formatInterpolations( + self, + unnamedPercent: list[str], + namedPercent: set[str], + formats: set[str], + ) -> str: + """Format the interpolations for display in an error message. + :param unnamedPercent: The unnamed percent interpolations. + :param namedPercent: The named percent interpolations. + :param formats: The brace format interpolations. + """ + out: list[str] = [] + if unnamedPercent: + out.append(f"unnamed percent interpolations in this order: {unnamedPercent}") + if namedPercent: + out.append(f"these named percent interpolations: {namedPercent}") + if formats: + out.append(f"these brace format interpolations: {formats}") + if not out: + return "no interpolations" + return "\n\tAnd ".join(out) + + def _checkMessage(self) -> None: + idUnnamedPercent, idNamedPercent, idFormats = self._getInterpolations(self._msgid) + if not self._msgstr[-1]: + return + strUnnamedPercent, strNamedPercent, strFormats = self._getInterpolations(self._msgstr[-1]) + error = False + alerts = [] + if idUnnamedPercent != strUnnamedPercent: + if idUnnamedPercent: + alerts.append("unnamed percent interpolations differ") + error = True + else: + alerts.append("unexpected presence of unnamed percent interpolations") + if idNamedPercent - strNamedPercent: + alerts.append("missing named percent interpolation") + error = True + if strNamedPercent - idNamedPercent: + if idNamedPercent: + alerts.append("extra named percent interpolation") + error = True + else: + alerts.append("unexpected presence of named percent interpolations") + if idFormats - strFormats: + alerts.append("missing brace format interpolation") + error = True + if strFormats - idFormats: + if idFormats: + alerts.append("extra brace format interpolation") + error = True + else: + alerts.append("unexpected presence of brace format interpolations") + if alerts: + self._messageAlert( + f"{', '.join(alerts)}\n" + f"Expected: {self._formatInterpolations(idUnnamedPercent, idNamedPercent, idFormats)}\n" + f"Got: {self._formatInterpolations(strUnnamedPercent, strNamedPercent, strFormats)}", + isError=error, + ) + + def getReport(self) -> str | None: + """Get a text report about any errors or warnings. + :return: The text or None if there were no problems. + """ + if not self.alerts: + return None + report = f"File {self._poPath}: " + if self.hasSyntaxError: + report += "syntax error" + else: + if self.errorCount: + msg = "error" if self.errorCount == 1 else "errors" + report += f"{self.errorCount} {msg}" + if self.warningCount: + if self.errorCount: + report += ", " + msg = "warning" if self.warningCount == 1 else "warnings" + report += f"{self.warningCount} {msg}" + report += "\n\n" + "\n\n".join(self.alerts) + return report + + +def checkPo(poFilePath: str) -> tuple[bool, str | None]: + """Check a po file for errors. + :param poFilePath: The path to the po file to check. + :return: + True if the file is okay or has warnings, False if there were fatal errors. + A report about the errors or warnings found, or None if there were no problems. + """ + c = _PoChecker(poFilePath) + report = None + if not c.check(): + report = c.getReport() + if report: + report = report.encode("cp1252", errors="backslashreplace").decode( + "utf-8", + errors="backslashreplace", + ) + return not bool(c.errorCount), report + + +def main(): + args = argparse.ArgumentParser() + commands = args.add_subparsers(title="commands", dest="command", required=True) + command_checkPo = commands.add_parser("checkPo", help="Check po files") + # Allow entering arbitrary po file paths, not just those in the source tree + command_checkPo.add_argument( + "poFilePaths", + help="Paths to the po file to check", + nargs="+", + ) + command_xliff2md = commands.add_parser("xliff2md", help="Convert xliff to markdown") + command_xliff2md.add_argument( + "-u", + "--untranslated", + help="Produce the untranslated markdown file", + action="store_true", + default=False, + ) + command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") + command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") + command_md2html = commands.add_parser("md2html", help="Convert markdown to html") + command_md2html.add_argument("-l", "--lang", help="Language code", action="store", default="en") + command_md2html.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + command_md2html.add_argument("mdPath", help="Path to the markdown file") + command_md2html.add_argument("htmlPath", help="Path to the resulting html file") + command_xliff2html = commands.add_parser("xliff2html", help="Convert xliff to html") + command_xliff2html.add_argument("-l", "--lang", help="Language code", action="store", required=False) + command_xliff2html.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + command_xliff2html.add_argument( + "-u", + "--untranslated", + help="Produce the untranslated markdown file", + action="store_true", + default=False, + ) + command_xliff2html.add_argument("xliffPath", help="Path to the xliff file") + command_xliff2html.add_argument("htmlPath", help="Path to the resulting html file") + uploadSourceFileCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + uploadSourceFileCommand.add_argument( + "-f", + "--localFilePath", + help="The local path to the file.", + ) + getFilesCommand = commands.add_parser( + "getFiles", + help="Get files from Crowdin.", + ) + downloadTranslationFileCommand = commands.add_parser( + "downloadTranslationFile", + help="Download a translation file from Crowdin.", + ) + downloadTranslationFileCommand.add_argument( + "language", + help="The language code to download the translation for.", + ) + downloadTranslationFileCommand.add_argument( + "crowdinFilePath", + help="The Crowdin file path", + ) + downloadTranslationFileCommand.add_argument( + "localFilePath", + nargs="?", + default=None, + help="The path to save the local file. If not provided, the Crowdin file path will be used.", + ) + uploadTranslationFileCommand = commands.add_parser( + "uploadTranslationFile", + help="Upload a translation file to Crowdin.", + ) + uploadTranslationFileCommand.add_argument( + "-o", + "--old", + help="Path to the old unchanged xliff file. If provided, only new or changed translations will be uploaded.", + default=None, + ) + uploadTranslationFileCommand.add_argument( + "language", + help="The language code to upload the translation for.", + ) + uploadTranslationFileCommand.add_argument( + "crowdinFilePath", + help="The Crowdin file path", + ) + uploadTranslationFileCommand.add_argument( + "localFilePath", + nargs="?", + default=None, + help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", + ) + + exportTranslationsCommand = commands.add_parser( + "exportTranslations", + help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", + ) + exportTranslationsCommand.add_argument( + "-o", + "--output", + help="Directory to save translation files", + required=True, + ) + exportTranslationsCommand.add_argument( + "-l", + "--language", + help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", + default=None, + ) + + args = args.parse_args() + match args.command: + case "xliff2md": + markdownTranslate.generateMarkdown( + xliffPath=args.xliffPath, + outputPath=args.mdPath, + translated=not args.untranslated, + ) + case "md2html": + md2html.main(source=args.mdPath, dest=args.htmlPath, lang=args.lang, docType=args.docType) + case "xliff2html": + lang = args.lang or fetchLanguageFromXliff(args.xliffPath, source=args.untranslated) + temp_mdFile = tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") + temp_mdFile.close() + try: + markdownTranslate.generateMarkdown( + xliffPath=args.xliffPath, + outputPath=temp_mdFile.name, + translated=not args.untranslated, + ) + md2html.main(source=temp_mdFile.name, dest=args.htmlPath, lang=lang, docType=args.docType) + finally: + os.remove(temp_mdFile.name) + case "uploadSourceFile": + uploadSourceFile(args.localFilePath) + case "getFiles": + getFiles() + case "downloadTranslationFile": + localFilePath = args.localFilePath or args.crowdinFilePath + downloadTranslationFile(args.crowdinFilePath, localFilePath, args.language) + if args.crowdinFilePath.endswith(".xliff"): + preprocessXliff(localFilePath, localFilePath) + elif localFilePath.endswith(".po"): + success, report = checkPo(localFilePath) + if report: + print(report) + if not success: + print(f"\nWarning: Po file {localFilePath} has fatal errors.") + case "checkPo": + poFilePaths = args.poFilePaths + badFilePaths: list[str] = [] + for poFilePath in poFilePaths: + success, report = checkPo(poFilePath) + if report: + print(report) + if not success: + badFilePaths.append(poFilePath) + if badFilePaths: + print(f"\nOne or more po files had fatal errors: {', '.join(badFilePaths)}") + sys.exit(1) + case "uploadTranslationFile": + localFilePath = args.localFilePath or args.crowdinFilePath + needsDelete = False + if args.crowdinFilePath.endswith(".xliff"): + tmp = tempfile.NamedTemporaryFile(suffix=".xliff", delete=False, mode="w") + tmp.close() + shutil.copyfile(localFilePath, tmp.name) + stripXliff(tmp.name, tmp.name, args.old) + localFilePath = tmp.name + needsDelete = True + elif localFilePath.endswith(".po"): + success, report = checkPo(localFilePath) + if report: + print(report) + if not success: + print(f"\nPo file {localFilePath} has errors. Upload aborted.") + sys.exit(1) + uploadTranslationFile(args.crowdinFilePath, localFilePath, args.language) + if needsDelete: + os.remove(localFilePath) + case "exportTranslations": + exportTranslations(args.output, args.language) + case _: + raise ValueError(f"Unknown command {args.command}") + + +if __name__ == "__main__": + main() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py new file mode 100644 index 0000000..341ead6 --- /dev/null +++ b/_l10n/markdownTranslate.py @@ -0,0 +1,733 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing import Generator +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 + +RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" +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-]+)\)(.*)$") + + +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("\\", "/") + return f"{RAW_GITHUB_REPO_URL}/{commitID}/{relativePath}" + + +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 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="")) + mdDiff = difflib.ndiff(origMdFile.readlines(), newMdFile.readlines()) + 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(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(), 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(file1.readlines(), 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/_l10n/md2html.py b/_l10n/md2html.py new file mode 100644 index 0000000..01acab0 --- /dev/null +++ b/_l10n/md2html.py @@ -0,0 +1,197 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2023-2024 NV Access Limited +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +import argparse +from copy import deepcopy +import io +import re +import shutil + +DEFAULT_EXTENSIONS = frozenset( + { + # Supports tables, HTML mixed with markdown, code blocks, custom attributes and more + "markdown.extensions.extra", + # Allows TOC with [TOC]" + "markdown.extensions.toc", + # Makes list behaviour better, including 2 space indents by default + "mdx_truly_sane_lists", + # External links will open in a new tab, and title will be set to the link text + "markdown_link_attr_modifier", + # Adds links to GitHub authors, issues and PRs + "mdx_gh_links", + }, +) + +EXTENSIONS_CONFIG = { + "markdown_link_attr_modifier": { + "new_tab": "external_only", + "auto_title": "on", + }, + "mdx_gh_links": { + "user": "nvaccess", + "repo": "nvda", + }, +} + +RTL_LANG_CODES = frozenset({"ar", "fa", "he"}) + +HTML_HEADERS = """ + + + + +{title} + + + +{extraStylesheet} + + +""".strip() + + +def _getTitle(mdBuffer: io.StringIO, isKeyCommands: bool = False) -> str: + if isKeyCommands: + TITLE_RE = re.compile(r"^$") + # Make next read at start of buffer + mdBuffer.seek(0) + for line in mdBuffer.readlines(): + match = TITLE_RE.match(line.strip()) + if match: + return match.group(1) + + raise ValueError("No KC:title command found in userGuide.md") + + else: + # Make next read at start of buffer + mdBuffer.seek(0) + # Remove heading hashes and trailing whitespace to get the tab title + title = mdBuffer.readline().strip().lstrip("# ") + + return title + + +def _createAttributeFilter() -> dict[str, set[str]]: + # Create attribute filter exceptions for HTML sanitization + import nh3 + + allowedAttributes: dict[str, set[str]] = deepcopy(nh3.ALLOWED_ATTRIBUTES) + + attributesWithAnchors = {"h1", "h2", "h3", "h4", "h5", "h6", "td"} + attributesWithClass = {"div", "span", "a", "th", "td"} + + # Allow IDs for anchors + for attr in attributesWithAnchors: + if attr not in allowedAttributes: + allowedAttributes[attr] = set() + allowedAttributes[attr].add("id") + + # Allow class for styling + for attr in attributesWithClass: + if attr not in allowedAttributes: + allowedAttributes[attr] = set() + allowedAttributes[attr].add("class") + + # link rel and target is set by markdown_link_attr_modifier + allowedAttributes["a"].update({"rel", "target"}) + + return allowedAttributes + + +ALLOWED_ATTRIBUTES = _createAttributeFilter() + + +def _generateSanitizedHTML(md: str, isKeyCommands: bool = False) -> str: + import markdown + import nh3 + + extensions = set(DEFAULT_EXTENSIONS) + if isKeyCommands: + from keyCommandsDoc import KeyCommandsExtension + + extensions.add(KeyCommandsExtension()) + + htmlOutput = markdown.markdown( + text=md, + extensions=extensions, + extension_configs=EXTENSIONS_CONFIG, + ) + + # Sanitize html output from markdown to prevent XSS from translators + htmlOutput = nh3.clean( + htmlOutput, + attributes=ALLOWED_ATTRIBUTES, + # link rel is handled by markdown_link_attr_modifier + link_rel=None, + # Keep key command comments and similar + strip_comments=False, + ) + + return htmlOutput + + +def main(source: str, dest: str, lang: str = "en", docType: str | None = None): + print(f"Converting {docType or 'document'} ({lang=}) at {source} to {dest}") + isUserGuide = docType == "userGuide" + isDevGuide = docType == "developerGuide" + isChanges = docType == "changes" + isKeyCommands = docType == "keyCommands" + if docType and not any([isUserGuide, isDevGuide, isChanges, isKeyCommands]): + raise ValueError(f"Unknown docType {docType}") + with open(source, "r", encoding="utf-8") as mdFile: + mdStr = mdFile.read() + + with io.StringIO() as mdBuffer: + mdBuffer.write(mdStr) + title = _getTitle(mdBuffer, isKeyCommands) + + if isUserGuide or isDevGuide: + extraStylesheet = '' + elif isChanges or isKeyCommands: + extraStylesheet = "" + else: + raise ValueError(f"Unknown target type for {dest}") + + htmlBuffer = io.StringIO() + htmlBuffer.write( + HTML_HEADERS.format( + lang=lang, + dir="rtl" if lang in RTL_LANG_CODES else "ltr", + title=title, + extraStylesheet=extraStylesheet, + ), + ) + + htmlOutput = _generateSanitizedHTML(mdStr, isKeyCommands) + # Make next write append at end of buffer + htmlBuffer.seek(0, io.SEEK_END) + htmlBuffer.write(htmlOutput) + + # Make next write append at end of buffer + htmlBuffer.seek(0, io.SEEK_END) + htmlBuffer.write("\n\n\n") + + with open(dest, "w", encoding="utf-8") as targetFile: + # Make next read at start of buffer + htmlBuffer.seek(0) + shutil.copyfileobj(htmlBuffer, targetFile) + + htmlBuffer.close() + + +if __name__ == "__main__": + args = argparse.ArgumentParser() + args.add_argument("-l", "--lang", help="Language code", action="store", default="en") + args.add_argument( + "-t", + "--docType", + help="Type of document", + action="store", + choices=["userGuide", "developerGuide", "changes", "keyCommands"], + ) + args.add_argument("source", help="Path to the markdown file") + args.add_argument("dest", help="Path to the resulting html file") + args = args.parse_args() + main(source=args.source, dest=args.dest, lang=args.lang, docType=args.docType) From 46694309932d6713effc0c0c951535b8a2128986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 11:14:14 +0100 Subject: [PATCH 002/100] use a json file to store addonId, and use it to filter files to get Crowdin ID --- _l10n/l10nUtil.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 00bee4c..2808258 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -23,7 +23,7 @@ CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -JSON_FILE = os.path.join(os.path.dirname(__file__), "files.json") +JSON_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") def fetchCrowdinAuthToken() -> str: From b18856045262e80c32815b055ad50e12d64aae0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 22:05:55 +0100 Subject: [PATCH 003/100] Try to get files just for the current add-on --- _l10n/l10nUtil.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 2808258..9480753 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -1,5 +1,5 @@ # A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2025 NV Access Limited. +# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. @@ -23,7 +23,9 @@ CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -JSON_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") +L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") def fetchCrowdinAuthToken() -> str: @@ -296,10 +298,14 @@ def uploadSourceFile(localFilePath: str): res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) -def getFiles() -> dict: +def getFiles() -> dict[str, str]: """Gets files from Crowdin, and write them to a json file.""" - res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, limit=500) + with open(METADATA_FILE, "R", encoding="utf-8") as jsonFile: + addonData = json.load(jsonFile) + addonId = addonData.get("addonId") + + res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) if res is None: raise ValueError("Getting files from Crowdin failed") dictionary = dict() @@ -309,8 +315,8 @@ def getFiles() -> dict: name = fileInfo["name"] id = fileInfo["id"] dictionary[name] = id - with open(JSON_FILE, "w", encoding="utf-8") as jsonFile: - json.dump(dictionary, jsonFile, ensure_ascii=False) + with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: + json.dump(dictionary, jsonFile, ensure_ascii=False) return dictionary @@ -321,7 +327,7 @@ def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: st :param localFilePath: The path to the local file to be uploaded :param language: The language code to upload the translation for """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(crowdinFilePath) if fileId is None: From 709261583343a4fb25a75ecfd1c4af81e7de22c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 24 Nov 2025 22:32:53 +0100 Subject: [PATCH 004/100] Add workflow to export an add-on to Crowdin (authors would need to be addedwith dev role to Crowdin if they use a project not owned by them to upload source files) --- .github/workflows/exportAddonToCrowdin.yml | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/exportAddonToCrowdin.yml diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml new file mode 100644 index 0000000..4ab508b --- /dev/null +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -0,0 +1,96 @@ +name: Export add-on to Crowdin + +on: + workflow_dispatch: + inputs: + repo: + description: 'Repository name' + required: true + update: + description: 'true to update preexisting sources, false to add them from scratch' + type: boolean + + workflow_call: + inputs: + repo: + description: 'Repository name' + type: 'string' + required: true + update: + description: 'true to update preexisting sources, false to add them from scratch' + type: boolean + required: false +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout add-on + uses: actions/checkout@v6 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install scons markdown + sudo apt update + sudo apt install gettext + - name: Build add-on and pot file + run: | + scons + scons pot + exportToCrowdin: + runs-on: ubuntu-latest + needs: build + permissions: + contents: write + steps: + - name: Checkout main branch + uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v7 + - name: Generate xliff + if: ${{ !inputs.update }} + run: | + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o inputs.repo }}.xliff + - name: update xliff + if: ${{ inputs.update }} + run: | + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ inputs.repo }}.xliff -m ${{ inputs.repo }}.md -o ${{ inputs.repo }}.xliff.temp + mv ${{ inputs.repo }}.xliff.temp ${{ inputs.repo }}.xliff + fi + - name: Upload to Crowdin + if: ${{ !inputs.update }} + run: | + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.pot + env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + - name: Update sources + if: ${{ inputs.update }} + run: | + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.xliff + env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + - name: Commit and push json file + id: commit + run: | + git config --local user.name github-actions + git config --local user.email github-actions@github.com + git status + git add _l10n/l10n.json + if git diff --staged --quiet; then + echo "Nothing added to commit." + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + git commit -m "Update Crowdin file ids" + git push + fi From e89640d95d7fa2c93e671584c9ec1cb0efc9ec96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 25 Nov 2025 05:36:33 +0100 Subject: [PATCH 005/100] Use buildVars, not metadata.json file --- _l10n/l10nUtil.py | 51 ++--------------------------------------------- 1 file changed, 2 insertions(+), 49 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 9480753..e7feef2 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -19,12 +19,11 @@ import zipfile import time import json +from .. import buildVars CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") -METADATA_FILE = os.path.join(os.path.dirname(__file__), "..", "metadata.json") L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") @@ -301,9 +300,7 @@ def uploadSourceFile(localFilePath: str): def getFiles() -> dict[str, str]: """Gets files from Crowdin, and write them to a json file.""" - with open(METADATA_FILE, "R", encoding="utf-8") as jsonFile: - addonData = json.load(jsonFile) - addonId = addonData.get("addonId") + addonId = buildVars.addon_info["addon_name"] res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) if res is None: @@ -802,35 +799,6 @@ def main(): ) command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - command_md2html = commands.add_parser("md2html", help="Convert markdown to html") - command_md2html.add_argument("-l", "--lang", help="Language code", action="store", default="en") - command_md2html.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - command_md2html.add_argument("mdPath", help="Path to the markdown file") - command_md2html.add_argument("htmlPath", help="Path to the resulting html file") - command_xliff2html = commands.add_parser("xliff2html", help="Convert xliff to html") - command_xliff2html.add_argument("-l", "--lang", help="Language code", action="store", required=False) - command_xliff2html.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - command_xliff2html.add_argument( - "-u", - "--untranslated", - help="Produce the untranslated markdown file", - action="store_true", - default=False, - ) - command_xliff2html.add_argument("xliffPath", help="Path to the xliff file") - command_xliff2html.add_argument("htmlPath", help="Path to the resulting html file") uploadSourceFileCommand = commands.add_parser( "uploadSourceFile", help="Upload a source file to Crowdin.", @@ -912,21 +880,6 @@ def main(): outputPath=args.mdPath, translated=not args.untranslated, ) - case "md2html": - md2html.main(source=args.mdPath, dest=args.htmlPath, lang=args.lang, docType=args.docType) - case "xliff2html": - lang = args.lang or fetchLanguageFromXliff(args.xliffPath, source=args.untranslated) - temp_mdFile = tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") - temp_mdFile.close() - try: - markdownTranslate.generateMarkdown( - xliffPath=args.xliffPath, - outputPath=temp_mdFile.name, - translated=not args.untranslated, - ) - md2html.main(source=temp_mdFile.name, dest=args.htmlPath, lang=lang, docType=args.docType) - finally: - os.remove(temp_mdFile.name) case "uploadSourceFile": uploadSourceFile(args.localFilePath) case "getFiles": From 4c7771b1f7fbeebc6c5bf3424f210bfcb2c99826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 16:43:35 +0100 Subject: [PATCH 006/100] Add userAccount to buildVars, and step to get addon-id to GitHub workflow to upload/update files in Crowdin --- .github/workflows/exportAddonToCrowdin.yml | 57 ++++++++++------------ _l10n/markdownTranslate.py | 8 ++- buildVars.py | 3 +- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4ab508b..40efcf3 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -1,21 +1,9 @@ name: Export add-on to Crowdin on: - workflow_dispatch: - inputs: - repo: - description: 'Repository name' - required: true - update: - description: 'true to update preexisting sources, false to add them from scratch' - type: boolean - workflow_call: + workflow_dispatch: inputs: - repo: - description: 'Repository name' - type: 'string' - required: true update: description: 'true to update preexisting sources, false to add them from scratch' type: boolean @@ -26,9 +14,15 @@ concurrency: jobs: build: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout add-on uses: actions/checkout@v6 + - name: "Set up Python" + uses: actions/setup-python@v6 + with: + python-version-file: ".python-version" - name: Install dependencies run: | python -m pip install --upgrade pip @@ -39,43 +33,42 @@ jobs: run: | scons scons pot - exportToCrowdin: - runs-on: ubuntu-latest - needs: build - permissions: - contents: write - steps: - - name: Checkout main branch - uses: actions/checkout@v6 - - name: "Set up Python" - uses: actions/setup-python@v6 - with: - python-version-file: ".python-version" - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Get add-on id + id: getAddonId + shell: python + run: | + import os + import buildVars + addonId = buildVars.addon_info["addon_name"] + name = 'addonId' + value = addonId + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}={value}"") - name: Generate xliff if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o inputs.repo }}.xliff + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ inputs.repo }}.xliff -m ${{ inputs.repo }}.md -o ${{ inputs.repo }}.xliff.temp - mv ${{ inputs.repo }}.xliff.temp ${{ inputs.repo }}.xliff + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp + mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff fi - name: Upload to Crowdin if: ${{ !inputs.update }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ inputs.repo}}.pot + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.pot env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - name: Update sources if: ${{ inputs.update }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ inputs.repo }}.xliff + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.xliff env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index 341ead6..5af1d73 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -17,7 +17,13 @@ from dataclasses import dataclass import subprocess -RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" + +from .. import buildVars + +addonId = buildVars.addon_info["addonname"] +userAccount = buildVars.userAccount +RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{userAccount}/{addonId}" + re_kcTitle = re.compile(r"^()$") re_kcSettingsSection = re.compile(r"^()$") # Comments that span a single line in their entirety diff --git a/buildVars.py b/buildVars.py index c125fae..770946a 100644 --- a/buildVars.py +++ b/buildVars.py @@ -10,7 +10,8 @@ # which returns whatever is given to it as an argument. from site_scons.site_tools.NVDATool.utils import _ - +# The GitHub user account to generate xliff file for translations +userAccount: str | None = None # Add-on information variables addon_info = AddonInfo( # add-on Name/identifier, internal for NVDA From c529cee4d5c4db819c75b08f7f77ef2d4c70d04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 21:36:41 +0100 Subject: [PATCH 007/100] Update files after testing exporting an add-on to Crowdin, needs refinements --- .github/workflows/exportAddonToCrowdin.yml | 7 +- _l10n/files.json | 2 +- _l10n/l10n.json | 1 + _l10n/l10nUtil.py | 62 +++++--- _l10n/markdownTranslate.py | 10 +- pyproject.toml | 176 ++------------------- 6 files changed, 60 insertions(+), 198 deletions(-) create mode 100644 _l10n/l10n.json diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 40efcf3..0b8dd9e 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -39,13 +39,14 @@ jobs: id: getAddonId shell: python run: | - import os + import os, sys + sys.path.insert(0, os.getcwd()) import buildVars addonId = buildVars.addon_info["addon_name"] name = 'addonId' value = addonId with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}"") + f.write(f"{name}={value}") - name: Generate xliff if: ${{ !inputs.update }} run: | @@ -53,7 +54,7 @@ jobs: - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff fi - name: Upload to Crowdin diff --git a/_l10n/files.json b/_l10n/files.json index 9264714..9e26dfe 100644 --- a/_l10n/files.json +++ b/_l10n/files.json @@ -1 +1 @@ -{"emoticons.pot": 176, "emoticons.xliff": 178, "goldwave.pot": 180, "goldwave.xliff": 182, "eMule.pot": 194, "enhancedTouchGestures.pot": 210, "resourceMonitor.pot": 214, "stationPlaylist.pot": 218, "cursorLocator.pot": 224, "pcKbBrl.pot": 228, "readFeeds.pot": 232, "reportSymbols.pot": 236, "urlShortener.pot": 240, "customNotifications.pot": 244, "readonlyProfiles.pot": 248, "enhancedAnnotations.pot": 252, "clipContentsDesigner.pot": 256, "clipContentsDesigner.xliff": 258, "controlUsageAssistant.pot": 260, "controlUsageAssistant.xliff": 262, "eMule.xliff": 264, "enhancedAnnotations.xliff": 266, "customNotifications.xliff": 268, "readonlyProfiles.xliff": 270, "urlShortener.xliff": 272, "reportSymbols.xliff": 274, "pcKbBrl.xliff": 276, "readFeeds.xliff": 278, "stationPlaylist.xliff": 282, "resourceMonitor.xliff": 284, "enhancedTouchGestures.xliff": 286, "rdAccess.pot": 288, "rdAccess.xliff": 290, "winMag.pot": 292, "winMag.xliff": 294, "charInfo.pot": 296, "charInfo.xliff": 298, "BMI.pot": 300, "BMI.xliff": 302, "tonysEnhancements.pot": 304, "tonysEnhancements.xliff": 306, "nvdaDevTestToolbox.pot": 308, "nvdaDevTestToolbox.xliff": 310, "easyTableNavigator.pot": 312, "easyTableNavigator.xliff": 314, "updateChannel.pot": 320, "updateChannel.xliff": 322, "instantTranslate.pot": 324, "instantTranslate.xliff": 326, "unicodeBrailleInput.pot": 328, "unicodeBrailleInput.xliff": 330, "columnsReview.pot": 332, "columnsReview.xliff": 334, "Access8Math.pot": 336, "Access8Math.xliff": 338, "systrayList.pot": 340, "systrayList.xliff": 342, "winWizard.pot": 344, "winWizard.xliff": 346, "speechLogger.pot": 348, "speechLogger.xliff": 350, "sayProductNameAndVersion.pot": 352, "sayProductNameAndVersion.xliff": 354, "objPad.pot": 356, "objPad.xliff": 358, "SentenceNav.pot": 360, "SentenceNav.xliff": 362, "wordNav.pot": 364, "wordNav.xliff": 366, "goldenCursor.pot": 368, "goldenCursor.xliff": 370, "MSEdgeDiscardAnnouncements.pot": 372, "MSEdgeDiscardAnnouncements.xliff": 374, "dayOfTheWeek.pot": 376, "dayOfTheWeek.xliff": 378, "outlookExtended.pot": 380, "outlookExtended.xliff": 382, "proxy.pot": 384, "proxy.xliff": 386, "searchWith.pot": 388, "searchWith.xliff": 390, "sayCurrentKeyboardLanguage.pot": 392, "sayCurrentKeyboardLanguage.xliff": 394, "robEnhancements.pot": 396, "robEnhancements.xliff": 398, "objWatcher.pot": 400, "objWatcher.xliff": 402, "mp3DirectCut.pot": 404, "mp3DirectCut.xliff": 406, "beepKeyboard.pot": 408, "beepKeyboard.xliff": 410, "numpadNavMode.pot": 412, "numpadNavMode.xliff": 414, "dropbox.pot": 416, "dropbox.xliff": 418, "reviewCursorCopier.pot": 420, "reviewCursorCopier.xliff": 422, "inputLock.pot": 424, "inputLock.xliff": 426, "debugHelper.pot": 428, "debugHelper.xliff": 430, "virtualRevision.pot": 432, "virtualRevision.xliff": 434, "cursorLocator.xliff": 436, "evtTracker.pot": 438, "evtTracker.xliff": 440} \ No newline at end of file +{} \ No newline at end of file diff --git a/_l10n/l10n.json b/_l10n/l10n.json new file mode 100644 index 0000000..abf3c01 --- /dev/null +++ b/_l10n/l10n.json @@ -0,0 +1 @@ +{"translateNvdaAddonsWithCrowdin.xliff": 442, "translateNvdaAddonsWithCrowdin.pot": 444} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index e7feef2..6cd4352 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -3,6 +3,9 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. +import os, sys +sys.path.insert(0, os.getcwd()) + import crowdin_api as crowdin import tempfile import lxml.etree @@ -10,7 +13,6 @@ import shutil import argparse import markdownTranslate -import md2html import requests import codecs import re @@ -19,7 +21,8 @@ import zipfile import time import json -from .. import buildVars + +import buildVars CROWDIN_PROJECT_ID = 780748 POLLING_INTERVAL_SECONDS = 5 @@ -237,7 +240,7 @@ def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: :param localFilePath: The path to save the local file :param language: The language code to download the translation for """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(crowdinFilePath) if fileId is None: @@ -263,7 +266,7 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ - with open(JSON_FILE, "r", encoding="utf-8") as jsonFile: + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: files = json.load(jsonFile) fileId = files.get(localFilePath) if fileId is None: @@ -282,19 +285,31 @@ def uploadSourceFile(localFilePath: str): match fileId: case None: if os.path.splitext(filename)[1] == ".pot": - title=f"{os.path.splitext(filename)[0]} interface" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = ( + f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + ) else: - title=f"{os.path.splitext(filename)[0]} documentation" - exportPattern =f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" exportOptions = { - "exportPattern": exportPattern + "exportPattern": exportPattern, } print(f"Importing source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file(storageId=storageId, projectId=CROWDIN_PROJECT_ID, name=filename, title=title, exportOptions=exportOptions) + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) print("Done") case _: - res = getCrowdinClient().source_files.update_file(fileId=fileId , storageId=storageId, projectId=CROWDIN_PROJECT_ID) + res = getCrowdinClient().source_files.update_file( + fileId=fileId, + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + ) def getFiles() -> dict[str, str]: @@ -799,19 +814,6 @@ def main(): ) command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - uploadSourceFileCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - uploadSourceFileCommand.add_argument( - "-f", - "--localFilePath", - help="The local path to the file.", - ) - getFilesCommand = commands.add_parser( - "getFiles", - help="Get files from Crowdin.", - ) downloadTranslationFileCommand = commands.add_parser( "downloadTranslationFile", help="Download a translation file from Crowdin.", @@ -854,7 +856,15 @@ def main(): default=None, help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", ) - + uploadSourceFileCommand = commands.add_parser( + "uploadSourceFile", + help="Upload a source file to Crowdin.", + ) + uploadSourceFileCommand.add_argument( + "-f", + "--localFilePath", + help="The local path to the file.", + ) exportTranslationsCommand = commands.add_parser( "exportTranslations", help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", @@ -869,7 +879,7 @@ def main(): "-l", "--language", help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", - default=None, + default=None, ) args = args.parse_args() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index 5af1d73..ee70eb7 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -17,17 +17,11 @@ from dataclasses import dataclass import subprocess - -from .. import buildVars - -addonId = buildVars.addon_info["addonname"] -userAccount = buildVars.userAccount -RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{userAccount}/{addonId}" - +RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" 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_comment = re.compile(r"^$", re.DOTALL) re_heading = re.compile(r"^(#+\s+)(.+?)((?:\s+\{#.+\})?)$") re_bullet = re.compile(r"^(\s*\*\s+)(.+)$") re_number = re.compile(r"^(\s*[0-9]+\.\s+)(.+)$") diff --git a/pyproject.toml b/pyproject.toml index 97189ac..44d0016 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,161 +1,17 @@ -[tool.ruff] -line-length = 110 - -builtins = [ - # translation lookup - "_", - # translation lookup - "ngettext", - # translation lookup - "pgettext", - # translation lookup - "npgettext", +[project] +name = "addonTemplate" +version = "0.1.0" +description = "Addon template" +readme = "readme.md" +requires-python = ">=3.13" +dependencies = [ + "crowdin-api-client==1.21.0", + "lxml>=6.0.1", + "markdown>=3.9", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + "mdx-truly-sane-lists==1.3", + "nh3==0.2.19", + "requests>=2.32.5", + "SCons==4.10.1", ] - -include = [ - "*.py", - "sconstruct", -] - -exclude = [ - ".git", - "__pycache__", -] - -[tool.ruff.format] -indent-style = "tab" - -[tool.ruff.lint.mccabe] -max-complexity = 15 - -[tool.ruff.lint] -ignore = [ - # indentation contains tabs - "W191", -] - -[tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, -# so ignore F821. -"sconstruct" = ["F821"] - -[tool.pyright] -pythonPlatform = "Windows" -typeCheckingMode = "strict" - -include = [ - "**/*.py", -] - -exclude = [ - "sconstruct", - ".git", - "__pycache__", - # When excluding concrete paths relative to a directory, - # not matching multiple folders by name e.g. `__pycache__`, - # paths are relative to the configuration file. -] - -# Tell pyright where to load python code from -extraPaths = [ - "./addon", -] - -# General config -analyzeUnannotatedFunctions = true -deprecateTypingAliases = true - -# Stricter typing -strictParameterNoneValue = true -strictListInference = true -strictDictionaryInference = true -strictSetInference = true - -# Compliant rules -reportAbstractUsage = true -reportArgumentType = true -reportAssertAlwaysTrue = true -reportAssertTypeFailure = true -reportAssignmentType = true -reportAttributeAccessIssue = true -reportCallInDefaultInitializer = true -reportCallIssue = true -reportConstantRedefinition = true -reportDuplicateImport = true -reportFunctionMemberAccess = true -reportGeneralTypeIssues = true -reportImplicitOverride = true -reportImplicitStringConcatenation = true -reportImportCycles = true -reportIncompatibleMethodOverride = true -reportIncompatibleVariableOverride = true -reportIncompleteStub = true -reportInconsistentConstructor = true -reportInconsistentOverload = true -reportIndexIssue = true -reportInvalidStringEscapeSequence = true -reportInvalidStubStatement = true -reportInvalidTypeArguments = true -reportInvalidTypeForm = true -reportInvalidTypeVarUse = true -reportMatchNotExhaustive = true -reportMissingImports = true -reportMissingModuleSource = true -reportMissingParameterType = true -reportMissingSuperCall = true -reportMissingTypeArgument = true -reportNoOverloadImplementation = true -reportOperatorIssue = true -reportOptionalCall = true -reportOptionalContextManager = true -reportOptionalIterable = true -reportOptionalMemberAccess = true -reportOptionalOperand = true -reportOptionalSubscript = true -reportOverlappingOverload = true -reportPossiblyUnboundVariable = true -reportPrivateImportUsage = true -reportPrivateUsage = true -reportPropertyTypeMismatch = true -reportRedeclaration = true -reportReturnType = true -reportSelfClsParameterName = true -reportShadowedImports = true -reportTypeCommentUsage = true -reportTypedDictNotRequiredAccess = true -reportUnboundVariable = true -reportUndefinedVariable = true -reportUnhashable = true -reportUninitializedInstanceVariable = true -reportUnknownArgumentType = true -reportUnknownLambdaType = true -reportUnknownMemberType = true -reportUnknownParameterType = true -reportUnknownVariableType = true -reportUnnecessaryCast = true -reportUnnecessaryComparison = true -reportUnnecessaryContains = true -reportUnnecessaryIsInstance = true -reportUnnecessaryTypeIgnoreComment = true -reportUnsupportedDunderAll = true -reportUntypedBaseClass = true -reportUntypedClassDecorator = true -reportUntypedFunctionDecorator = true -reportUntypedNamedTuple = true -reportUnusedCallResult = true -reportUnusedClass = true -reportUnusedCoroutine = true -reportUnusedExcept = true -reportUnusedExpression = true -reportUnusedFunction = true -reportUnusedImport = true -reportUnusedVariable = true -reportWildcardImportFromLibrary = true - -reportDeprecated = true - -# Can be enabled by generating type stubs for modules via pyright CLI -reportMissingTypeStubs = false - -# Bad rules -# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 186b75593a0b4619d944c1f243a26eb8191584ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 21:38:12 +0100 Subject: [PATCH 008/100] Add python version file --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 From f1fbf8e39fa542091bdfd52b003514f1e30a370b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 26 Nov 2025 22:14:03 +0100 Subject: [PATCH 009/100] Improve pyproject and update precommit config after testing that check pass creating a PR at nvdaes/translateNvdaaddonsWithCrowdin repo --- .pre-commit-config.yaml | 97 ++++++++++++++++++-- pyproject.toml | 197 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 285 insertions(+), 9 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd7a9d6..75d507a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,92 @@ +# Copied from https://github.com/nvaccess/nvda +# https://pre-commit.ci/ +# Configuration for Continuous Integration service +ci: + # Pyright does not seem to work in pre-commit CI + skip: [pyright] + autoupdate_schedule: monthly + autoupdate_commit_msg: "Pre-commit auto-update" + autofix_commit_msg: "Pre-commit auto-fix" + submodules: true + +default_language_version: + python: python3.13 + repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 - hooks: - - id: check-ast - - id: check-case-conflict - - id: check-yaml +- repo: https://github.com/pre-commit-ci/pre-commit-ci-config + rev: v1.6.1 + hooks: + - id: check-pre-commit-ci-config + +- repo: meta + hooks: + # ensures that exclude directives apply to any file in the repository. + - id: check-useless-excludes + # ensures that the configured hooks apply to at least one file in the repository. + - id: check-hooks-apply + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Prevents commits to certain branches + - id: no-commit-to-branch + args: ["--branch", "main", ] + # Checks that large files have not been added. Default cut-off for "large" files is 500kb. + - id: check-added-large-files + # Checks python syntax + - id: check-ast + # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) + - id: check-case-conflict + # Checks for artifacts from resolving merge conflicts. + - id: check-merge-conflict + # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. + - id: debug-statements + # Removes trailing whitespace. + - id: trailing-whitespace + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Ensures all files end in 1 (and only 1) newline. + - id: end-of-file-fixer + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Removes the UTF-8 BOM from files that have it. + # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding + - id: fix-byte-order-marker + types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] + # Validates TOML files. + - id: check-toml + # Validates YAML files. + - id: check-yaml + # Ensures that links to lines in files under version control point to a particular commit. + - id: check-vcs-permalinks + # Avoids using reserved Windows filenames. + - id: check-illegal-windows-names +- repo: https://github.com/asottile/add-trailing-comma + rev: v3.2.0 + hooks: + # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, + # if a trailing comma is added. + # This adds a trailing comma to args/iterable items in case it was missed. + - id: add-trailing-comma + +- repo: https://github.com/astral-sh/ruff-pre-commit + # Matches Ruff version in pyproject. + rev: v0.12.7 + hooks: + - id: ruff + name: lint with ruff + args: [ --fix ] + - id: ruff-format + name: format with ruff + +- repo: https://github.com/RobertCraigie/pyright-python + rev: v1.1.406 + hooks: + - id: pyright + name: Check types with pyright + additional_dependencies: [ "pyright[nodejs]==1.1.406" ] + +- repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.18.1 + hooks: + - id: markdownlint-cli2 + name: Lint markdown files + args: ["--fix"] diff --git a/pyproject.toml b/pyproject.toml index 44d0016..ab571c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,9 +1,26 @@ +[build-system] +requires = ["setuptools~=72.0", "wheel"] +build-backend = "setuptools.build_meta" + [project] name = "addonTemplate" +dynamic = ["version"] version = "0.1.0" -description = "Addon template" -readme = "readme.md" -requires-python = ">=3.13" +description = "Add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme="readme.md" +license = {file = "LICENSE"} dependencies = [ "crowdin-api-client==1.21.0", "lxml>=6.0.1", @@ -15,3 +32,177 @@ dependencies = [ "requests>=2.32.5", "SCons==4.10.1", ] + +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", + ".venv", + "buildVars.py", +] + +[tool.ruff.format] +indent-style = "tab" +line-ending = "lf" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] +logger-objects = ["logHandler.log"] + +[tool.ruff.lint.per-file-ignores] +# sconscripts contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +venvPath = "../nvda/.venv" +venv = "." +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + ".venv", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", + "../nvda/source", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportDuplicateImport = true +reportIncompleteStub = true +reportInconsistentOverload = true +reportInconsistentConstructor = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingModuleSource = true +reportMissingImports = true +reportNoOverloadImplementation = true +reportOptionalContextManager = true +reportOverlappingOverload = true +reportPrivateImportUsage = true +reportPropertyTypeMismatch = true +reportSelfClsParameterName = true +reportShadowedImports = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUndefinedVariable = true +reportUnusedExpression = true +reportUnboundVariable = true +reportUnhashable = true +reportUnnecessaryCast = true +reportUnnecessaryContains = true +reportUnnecessaryTypeIgnoreComment = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportDeprecated = true +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false +reportUnsupportedDunderAll = false +reportAbstractUsage = false +reportUntypedBaseClass = false +reportOptionalIterable = false +reportCallInDefaultInitializer = false +reportInvalidTypeArguments = false +reportUntypedNamedTuple = false +reportRedeclaration = false +reportOptionalCall = false +reportConstantRedefinition = false +reportWildcardImportFromLibrary = false +reportIncompatibleVariableOverride = false +reportInvalidTypeForm = false +reportGeneralTypeIssues = false +reportOptionalOperand = false +reportUnnecessaryComparison = false +reportFunctionMemberAccess = false +reportUnnecessaryIsInstance = false +reportUnusedFunction = false +reportImportCycles = false +reportUnusedImport = false +reportUnusedVariable = false +reportOperatorIssue = false +reportAssignmentType = false +reportReturnType = false +reportPossiblyUnboundVariable = false +reportMissingSuperCall = false +reportUninitializedInstanceVariable = false +reportUnknownLambdaType = false +reportMissingTypeArgument = false +reportImplicitStringConcatenation = false +reportIncompatibleMethodOverride = false +reportPrivateUsage = false +reportUnusedCallResult = false +reportOptionalSubscript = false +reportCallIssue = false +reportOptionalMemberAccess = false +reportImplicitOverride = false +reportIndexIssue = false +reportAttributeAccessIssue = false +reportArgumentType = false +reportUnknownParameterType = false +reportMissingParameterType = false +reportUnknownVariableType = false +reportUnknownArgumentType = false +reportUnknownMemberType = false +lint = [ + "ruff==0.8.1", + "pre-commit==4.0.1", + "pyright==1.1.396", +] + From b867a9a0f25aa5d5b56bca11c12083fe5298795e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 07:05:11 +0100 Subject: [PATCH 010/100] Restore rules --- pyproject.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index ab571c4..bf69408 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -118,18 +118,37 @@ strictDictionaryInference = true strictSetInference = true # Compliant rules +reportAbstractUsage = true +reportArgumentType = true reportAssertAlwaysTrue = true reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true reportIncompleteStub = true +reportIndexIssue = true reportInconsistentOverload = true reportInconsistentConstructor = true reportInvalidStringEscapeSequence = true reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true reportMissingModuleSource = true reportMissingImports = true +reportMissingParameterType = true +reportMissingSuperCall = true reportNoOverloadImplementation = true reportOptionalContextManager = true reportOverlappingOverload = true From 47ed91cde7c16799122428ac0d50b294aa89ffa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 20:44:09 +0100 Subject: [PATCH 011/100] Restore pyproject --- pyproject.toml | 140 +++++++++++++------------------------------------ 1 file changed, 37 insertions(+), 103 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index bf69408..97189ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,41 +1,3 @@ -[build-system] -requires = ["setuptools~=72.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "addonTemplate" -dynamic = ["version"] -version = "0.1.0" -description = "Add-on template" -maintainers = [ - {name = "NV Access", email = "info@nvaccess.org"}, -] -requires-python = ">=3.13,<3.14" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python :: 3", - "Topic :: Accessibility", -] -readme="readme.md" -license = {file = "LICENSE"} -dependencies = [ - "crowdin-api-client==1.21.0", - "lxml>=6.0.1", - "markdown>=3.9", - "markdown-link-attr-modifier==0.2.1", - "mdx-gh-links==0.4", - "mdx-truly-sane-lists==1.3", - "nh3==0.2.19", - "requests>=2.32.5", - "SCons==4.10.1", -] - -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" - [tool.ruff] line-length = 110 @@ -58,13 +20,10 @@ include = [ exclude = [ ".git", "__pycache__", - ".venv", - "buildVars.py", ] [tool.ruff.format] indent-style = "tab" -line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -74,16 +33,13 @@ ignore = [ # indentation contains tabs "W191", ] -logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconscripts contains many inbuilt functions not recognised by the lint, +# sconstruct contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] -venvPath = "../nvda/.venv" -venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -95,7 +51,6 @@ exclude = [ "sconstruct", ".git", "__pycache__", - ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -104,7 +59,6 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", - "../nvda/source", ] # General config @@ -136,92 +90,72 @@ reportImportCycles = true reportIncompatibleMethodOverride = true reportIncompatibleVariableOverride = true reportIncompleteStub = true -reportIndexIssue = true -reportInconsistentOverload = true reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true reportInvalidStringEscapeSequence = true reportInvalidStubStatement = true reportInvalidTypeArguments = true reportInvalidTypeForm = true reportInvalidTypeVarUse = true reportMatchNotExhaustive = true -reportMissingModuleSource = true reportMissingImports = true +reportMissingModuleSource = true reportMissingParameterType = true reportMissingSuperCall = true +reportMissingTypeArgument = true reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true reportOverlappingOverload = true +reportPossiblyUnboundVariable = true reportPrivateImportUsage = true +reportPrivateUsage = true reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true reportSelfClsParameterName = true reportShadowedImports = true reportTypeCommentUsage = true reportTypedDictNotRequiredAccess = true -reportUndefinedVariable = true -reportUnusedExpression = true reportUnboundVariable = true +reportUndefinedVariable = true reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true reportUnnecessaryCast = true +reportUnnecessaryComparison = true reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true reportUntypedClassDecorator = true reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true reportUnusedClass = true reportUnusedCoroutine = true reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + reportDeprecated = true + # Can be enabled by generating type stubs for modules via pyright CLI reportMissingTypeStubs = false -reportUnsupportedDunderAll = false -reportAbstractUsage = false -reportUntypedBaseClass = false -reportOptionalIterable = false -reportCallInDefaultInitializer = false -reportInvalidTypeArguments = false -reportUntypedNamedTuple = false -reportRedeclaration = false -reportOptionalCall = false -reportConstantRedefinition = false -reportWildcardImportFromLibrary = false -reportIncompatibleVariableOverride = false -reportInvalidTypeForm = false -reportGeneralTypeIssues = false -reportOptionalOperand = false -reportUnnecessaryComparison = false -reportFunctionMemberAccess = false -reportUnnecessaryIsInstance = false -reportUnusedFunction = false -reportImportCycles = false -reportUnusedImport = false -reportUnusedVariable = false -reportOperatorIssue = false -reportAssignmentType = false -reportReturnType = false -reportPossiblyUnboundVariable = false -reportMissingSuperCall = false -reportUninitializedInstanceVariable = false -reportUnknownLambdaType = false -reportMissingTypeArgument = false -reportImplicitStringConcatenation = false -reportIncompatibleMethodOverride = false -reportPrivateUsage = false -reportUnusedCallResult = false -reportOptionalSubscript = false -reportCallIssue = false -reportOptionalMemberAccess = false -reportImplicitOverride = false -reportIndexIssue = false -reportAttributeAccessIssue = false -reportArgumentType = false -reportUnknownParameterType = false -reportMissingParameterType = false -reportUnknownVariableType = false -reportUnknownArgumentType = false -reportUnknownMemberType = false -lint = [ - "ruff==0.8.1", - "pre-commit==4.0.1", - "pyright==1.1.396", -] +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 402002eb5a86e14e241c6df5aaade0fa7acc3ebf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 21:43:29 +0100 Subject: [PATCH 012/100] Improve uv project --- .gitignore | 18 +++- 2.32.5 | 0 3.9 | 0 6.0.1 | 0 pyproject.toml | 52 +++++++++- uv.lock | 267 +++++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 333 insertions(+), 4 deletions(-) create mode 100644 2.32.5 create mode 100644 3.9 create mode 100644 6.0.1 create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 0be8af1..1750f2c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,23 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv + +# Files generated for add-ons addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -manifest.ini +addon/*.ini +addon/locale/*/*.ini *.mo *.pot -*.py[co] +*.pyc *.nvda-addon .sconsign.dblite -/[0-9]*.[0-9]*.[0-9]*.json diff --git a/2.32.5 b/2.32.5 new file mode 100644 index 0000000..e69de29 diff --git a/3.9 b/3.9 new file mode 100644 index 0000000..e69de29 diff --git a/6.0.1 b/6.0.1 new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 97189ac..4673a1c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,39 @@ +[build-system] +requires = ["setuptools~=72.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "addonTemplate" +dynamic = ["version"] +description = "NVDA add-on template" +maintainers = [ + {name = "NV Access", email = "info@nvaccess.org"}, +] +requires-python = ">=3.13,<3.14" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python :: 3", + "Topic :: Accessibility", +] +readme = "readme.md" +license = {file = "COPYING.TXT"} +dependencies = [ + "crowdin-api-client==1.21.0", + "lxml>=6.0.2", + "markdown>=3.10", + "markdown-link-attr-modifier==0.2.1", + "mdx-gh-links==0.4", + "mdx-truly-sane-lists==1.3", + "nh3==0.2.19", + "requests>=2.32.5", + "scons==4.10.1", +] +[project.urls] +Repository = "https://github.com/nvaccess/addonTemplate" + [tool.ruff] line-length = 110 @@ -20,10 +56,13 @@ include = [ exclude = [ ".git", "__pycache__", + ".venv", + "buildVars.py", ] [tool.ruff.format] indent-style = "tab" +line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -33,13 +72,16 @@ ignore = [ # indentation contains tabs "W191", ] +logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, +# sconscripts contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] +venvPath = "../nvda/.venv" +venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -51,6 +93,7 @@ exclude = [ "sconstruct", ".git", "__pycache__", + ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -59,6 +102,7 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", + "../nvda/source", ] # General config @@ -159,3 +203,9 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. +lint = [ + "ruff==0.8.1", + "pre-commit==4.0.1", + "pyright==1.1.396", +] + diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..58c3f26 --- /dev/null +++ b/uv.lock @@ -0,0 +1,267 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "requests" }, + { name = "scons" }, +] + +[package.metadata] +requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.21.0" }, + { name = "lxml", specifier = ">=6.0.2" }, + { name = "markdown", specifier = ">=3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.2.19" }, + { name = "requests", specifier = ">=2.32.5" }, + { name = "scons", specifier = "==4.10.1" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/70/4069412e2e8004a6ad15bf2a3d9085bea50ee932a66ad935285831cf82b4/crowdin_api_client-1.21.0.tar.gz", hash = "sha256:0f957e5de6487a74ac892d524a5e300c1bc971320b67f85ce65741904420d8ec", size = 64729, upload-time = "2025-01-31T15:56:42.179Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/0f/9efc4f6db0b3b97f99015529b5832058ce4f7970d547b23fc04a38d69ddd/crowdin_api_client-1.21.0-py3-none-any.whl", hash = "sha256:85e19557755ebf6a15beda605d25b77de365244d8c636462b0dd8030a6cdfe20", size = 101566, upload-time = "2025-01-31T15:56:40.651Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.2.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/32/3b8d8471d006333bac3175ad37402414d985ed3f8650a01a33e0e86b9824/nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804", size = 17327, upload-time = "2024-11-30T04:05:56.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/1d/cbd75a2313d96cd3903111667d3d07548fb45c8ecf5c315f37a8f6c202fa/nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6", size = 1205181, upload-time = "2024-11-29T05:50:02.802Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/8e9ec472ce575fa6b98935920c91df637bf9342862bd943745441aec99eb/nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0", size = 739174, upload-time = "2024-11-29T05:50:10.157Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b5/d1f81c5ec5695464b69d8aa4529ecb5fd872cbfb29f879b4063bb9397da8/nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37", size = 758660, upload-time = "2024-11-29T05:50:13.97Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5e/295a3a069f3b9dc35527eedd7b212f31311ef1f66a0e5f5f0acad6db9456/nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a", size = 924377, upload-time = "2024-11-29T05:50:15.716Z" }, + { url = "https://files.pythonhosted.org/packages/71/e2/0f189d5054f22cdfdb16d16a2a41282f411a4c03f8418be47e0480bd5bfd/nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd", size = 992124, upload-time = "2024-11-29T05:50:17.637Z" }, + { url = "https://files.pythonhosted.org/packages/0d/87/2907edd61a2172527c5322036aa95ce6c18432ff280fc5cf78fe0f934c65/nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9", size = 913939, upload-time = "2024-11-29T05:50:20.435Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a2/e0d3ea0175f28032d7d2bab765250f4e94ef131a7b3293e3df4cb254a5b2/nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55", size = 909051, upload-time = "2024-11-29T05:50:31.116Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/f52cb1d54ba965b7d8bb1c884ca982be31d7f75ad9e7e5817f4af20002b3/nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc", size = 540566, upload-time = "2024-11-29T05:50:38.437Z" }, + { url = "https://files.pythonhosted.org/packages/70/85/91a66edfab0adbf22468973d8abd4b93c951bbcbbe2121675ee468b912a2/nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3", size = 542368, upload-time = "2024-11-29T05:50:41.418Z" }, + { url = "https://files.pythonhosted.org/packages/32/9c/f8808cf6683d4852ba8862e25b98aa9116067ddec517938a1b6e8faadb43/nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41", size = 1205140, upload-time = "2024-11-29T05:50:54.582Z" }, + { url = "https://files.pythonhosted.org/packages/04/0e/268401d9244a84935342d9f3ba5d22bd7d2fc10cfc7a8f59bde8f6721466/nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a", size = 763571, upload-time = "2024-11-29T05:51:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/8be706feb6637d6e5db0eed09fd3f4e1008aee3d5d7161c9973d7aae1d13/nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69", size = 750319, upload-time = "2024-11-29T05:51:05.512Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ce/1f5f9ba0194f6a882e4bda89ae831678e4b68aa3de91e11e2629a1e6a613/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9", size = 857636, upload-time = "2024-11-29T05:51:06.872Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5d/5661a66f2950879a81fde5fbb6beb650c5647776aaec1a676e6b3ff4b6e5/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c", size = 821081, upload-time = "2024-11-29T05:51:08.202Z" }, + { url = "https://files.pythonhosted.org/packages/22/f8/454828f6f21516bf0c8c578e8bc2ab4f045e6b6fe5179602fe4dc2479da6/nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1", size = 894452, upload-time = "2024-11-29T05:51:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/2e/5d/36f5b78cbc631cac1c993bdc4608a0fe3148214bdb6d2c1266e228a2686a/nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc", size = 748281, upload-time = "2024-11-29T05:51:29.021Z" }, + { url = "https://files.pythonhosted.org/packages/98/da/d04f5f0e7ee8edab8ceecdbba9f1c614dc8cf07374141ff6ea3b615b3479/nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707", size = 767109, upload-time = "2024-11-29T05:51:31.222Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5b/1232fb35c7d1182adb7d513fede644a81b5361259749781e6075c40a9125/nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22", size = 924295, upload-time = "2024-11-29T05:51:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fd/ae622d08518fd31360fd87a515700bc09913f2e57e7f010063f2193ea610/nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d", size = 992038, upload-time = "2024-11-29T05:51:34.414Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/226577c5e3fe379cb95265aa77736e191d859032c974169e6879c51c156f/nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9", size = 913866, upload-time = "2024-11-29T05:51:35.727Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ca/bbd2b2dab31ceae38cfa673861cab81df5ed5be1fe47b6c4f5aa41729aa2/nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0", size = 908976, upload-time = "2024-11-29T05:51:43.698Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8f/6452eb1184ad87cdd2cac7ee3ebd67a2aadb554d25572c1778efdf807e1e/nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48", size = 540528, upload-time = "2024-11-29T05:51:45.312Z" }, + { url = "https://files.pythonhosted.org/packages/58/d6/285df10307f16fcce9afbd133b04b4bc7d7f9b84b02f0f724bab30dacdd9/nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121", size = 542316, upload-time = "2024-11-29T05:52:01.253Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] From d82071137cd1e13db770fc2a3de98b9dc8363f03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 27 Nov 2025 21:44:57 +0100 Subject: [PATCH 013/100] Remove files --- 2.32.5 | 0 3.9 | 0 6.0.1 | 0 3 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 2.32.5 delete mode 100644 3.9 delete mode 100644 6.0.1 diff --git a/2.32.5 b/2.32.5 deleted file mode 100644 index e69de29..0000000 diff --git a/3.9 b/3.9 deleted file mode 100644 index e69de29..0000000 diff --git a/6.0.1 b/6.0.1 deleted file mode 100644 index e69de29..0000000 From 9f6b3dc35c6d3ff8ad4d6be6200248885d2aec20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 29 Nov 2025 17:41:31 +0100 Subject: [PATCH 014/100] Calculate hash of i18nSources --- .github/workflows/exportAddonToCrowdin.yml | 33 ++++++++++++++-------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 0b8dd9e..857f1d6 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -35,41 +35,52 @@ jobs: scons pot - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Get add-on id - id: getAddonId + - name: Get add-on info + id: getAddonInfo shell: python run: | - import os, sys + import os, sys, hashlib sys.path.insert(0, os.getcwd()) import buildVars addonId = buildVars.addon_info["addon_name"] + i18nSources = buildVars.i18nSources + hasher = hashlib.sha256() + for file in i18nSources: + if os.path.isfile(file): + with open(file, "rb") as f: + while chunk := f.read(8192): + hasher.update(chunk) + hashValue = hasher.hexdigest() name = 'addonId' value = addonId + name2 = 'hashValue' + value2 = hashValue + print(hashValue) with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}") + f.write(f"{name}={value}\n{name2}={value2}") - name: Generate xliff if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff - name: update xliff if: ${{ inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonId.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonId.outputs.addonId }}.xliff.temp - mv ${{ steps.getAddonId.outputs.addonId }}.xliff.temp ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp + mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff fi - name: Upload to Crowdin if: ${{ !inputs.update }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonId.outputs.addonId }}.pot + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - name: Update sources if: ${{ inputs.update }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonId.outputs.addonId }}.xliff + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} From 4c938eccedc7d8836482c27bdaa0aa68f72983e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 30 Nov 2025 14:35:49 +0100 Subject: [PATCH 015/100] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 76 ++++++++++++---------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 857f1d6..1547b3c 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -8,9 +8,13 @@ on: description: 'true to update preexisting sources, false to add them from scratch' type: boolean required: false + default: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +env: + crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} jobs: build: runs-on: ubuntu-latest @@ -39,63 +43,63 @@ jobs: id: getAddonInfo shell: python run: | - import os, sys, hashlib + import os, sys, json sys.path.insert(0, os.getcwd()) - import buildVars + import buildVars, sha256 addonId = buildVars.addon_info["addon_name"] - i18nSources = buildVars.i18nSources - hasher = hashlib.sha256() - for file in i18nSources: - if os.path.isfile(file): - with open(file, "rb") as f: - while chunk := f.read(8192): - hasher.update(chunk) - hashValue = hasher.hexdigest() + readmeFile = os.path.join(os.getcwd(), "readme.md") + i18nSources = sorted(buildVars.i18nSources) + if os.path.isfile(readmeFile): + readmeSha = sha256.sha256_checksum([readmeFile]) + i18nSourcesSha = sha256.sha256_checksum(i18nSources) + hashFile = os.path.join(os.getcwd(), "hash.json") + data = dict() + if os.path.isfile(hashFile): + with open(hashFile, "rt") as f: + data = json.load(f) + shouldUpdateXliff = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(f"{addonId}.xliff") + shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + data = dict() + if readmeSha: + data["readmeSha"] = readmeSha + if i18nSourcesSha: + data["i18nSourcesSha"] = i18nSourcesSha + with open(hashFile, "wt", encoding="utf-8") as f: + json.dump(data, f, indent="\t", ensure_ascii=False) name = 'addonId' value = addonId - name2 = 'hashValue' - value2 = hashValue - print(hashValue) + name0 = 'shouldUpdateXliff' + value0 = str(shouldUpdateXliff).lower() + name1 = 'shouldUpdatePot' + value1 = str(shouldUpdatePot).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name2}={value2}") - - name: Generate xliff + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + - name: Generate xliff and pot if: ${{ !inputs.update }} run: | uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update xliff - if: ${{ inputs.update }} + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateXliff == 'true' }} run: | uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff - fi - - name: Upload to Crowdin - if: ${{ !inputs.update }} - run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} - crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - - name: Update sources - if: ${{ inputs.update }} + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff + - name: Update pot + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff - env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} - crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} - - name: Commit and push json file + - name: Commit and push json and xliff files id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add _l10n/l10n.json + git add hash.json _l10n/l10n.json ${{ steps.getAddonInfo.outputs.addonId}}.xliff if git diff --staged --quiet; then echo "Nothing added to commit." - echo "has_changes=false" >> $GITHUB_OUTPUT else - echo "has_changes=true" >> $GITHUB_OUTPUT - git commit -m "Update Crowdin file ids" + git commit -m "Update Crowdin file ids and hashes" git push fi From a3032100afc7facefc898e15f12a1041c481faf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 30 Nov 2025 18:16:35 +0100 Subject: [PATCH 016/100] Update _l10n --- _l10n/files.json | 1 - _l10n/l10n.json | 2 +- _l10n/markdownTranslate.py | 6 +++++- 3 files changed, 6 insertions(+), 3 deletions(-) delete mode 100644 _l10n/files.json diff --git a/_l10n/files.json b/_l10n/files.json deleted file mode 100644 index 9e26dfe..0000000 --- a/_l10n/files.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/_l10n/l10n.json b/_l10n/l10n.json index abf3c01..9e26dfe 100644 --- a/_l10n/l10n.json +++ b/_l10n/l10n.json @@ -1 +1 @@ -{"translateNvdaAddonsWithCrowdin.xliff": 442, "translateNvdaAddonsWithCrowdin.pot": 444} \ No newline at end of file +{} \ No newline at end of file diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py index ee70eb7..fa9a186 100644 --- a/_l10n/markdownTranslate.py +++ b/_l10n/markdownTranslate.py @@ -6,6 +6,8 @@ from typing import Generator import tempfile import os +import sys +sys.path.insert(0, os.getcwd()) import contextlib import lxml.etree import argparse @@ -17,7 +19,9 @@ from dataclasses import dataclass import subprocess -RAW_GITHUB_REPO_URL = "https://raw.githubusercontent.com/nvaccess/nvda" +import buildVars + +RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{buildVars.userAccount}/{buildVars.addon_info["addon_name"]}" re_kcTitle = re.compile(r"^()$") re_kcSettingsSection = re.compile(r"^()$") # Comments that span a single line in their entirety From a0d02da4a1e2acd155286cd516ec5aa3d4bb5eef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 1 Dec 2025 21:31:13 +0100 Subject: [PATCH 017/100] Upload md file --- .github/workflows/exportAddonToCrowdin.yml | 25 +++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1547b3c..4ef3da6 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -57,7 +57,7 @@ jobs: if os.path.isfile(hashFile): with open(hashFile, "rt") as f: data = json.load(f) - shouldUpdateXliff = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(f"{addonId}.xliff") + shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) data = dict() if readmeSha: @@ -68,24 +68,25 @@ jobs: json.dump(data, f, indent="\t", ensure_ascii=False) name = 'addonId' value = addonId - name0 = 'shouldUpdateXliff' - value0 = str(shouldUpdateXliff).lower() + name0 = 'shouldUpdateMd' + value0 = str(shouldUpdateMd).lower() name1 = 'shouldUpdatePot' value1 = str(shouldUpdatePot).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") - - name: Generate xliff and pot + - name: Generate source files if: ${{ !inputs.update }} run: | - uv run ./_l10n/markdownTranslate.py generateXliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.xliff + if -f readme.md; then + mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md + fi uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: update xliff - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateXliff == 'true' }} + - name: update md + if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | - uv run ./_l10n/markdownTranslate.py updateXliff -x ${{ steps.getAddonInfo.outputs.addonId }}.xliff -m readme.md -o ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp - mv ${{ steps.getAddonInfo.outputs.addonId }}.xliff.temp ${{ steps.getAddonInfo.outputs.addonId }}.xliff - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.xliff + mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Update pot if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | @@ -96,7 +97,7 @@ jobs: git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add hash.json _l10n/l10n.json ${{ steps.getAddonInfo.outputs.addonId}}.xliff + git add hash.json _l10n/l10n.json if git diff --staged --quiet; then echo "Nothing added to commit." else From a8d42520dcd71d303f6aaa1cd073e29051f1b3ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 3 Dec 2025 18:59:28 +0100 Subject: [PATCH 018/100] Updates --- _l10n/l10n.json | 1 - _l10n/l10nUtil.py | 64 ++++++++++++++++++----------------------------- 2 files changed, 24 insertions(+), 41 deletions(-) delete mode 100644 _l10n/l10n.json diff --git a/_l10n/l10n.json b/_l10n/l10n.json deleted file mode 100644 index 9e26dfe..0000000 --- a/_l10n/l10n.json +++ /dev/null @@ -1 +0,0 @@ -{} \ No newline at end of file diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 6cd4352..68725aa 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -24,7 +24,8 @@ import buildVars -CROWDIN_PROJECT_ID = 780748 + +CROWDIN_PROJECT_ID = os.getenv("crowdinProjectID", "").strip() POLLING_INTERVAL_SECONDS = 5 EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") @@ -266,12 +267,6 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(localFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(localFilePath) res = getCrowdinClient().storages.add_storage( open(localFilePath, "rb"), ) @@ -279,40 +274,30 @@ def uploadSourceFile(localFilePath: str): raise ValueError("Crowdin storage upload failed") storageId = res["data"]["id"] print(f"Stored with ID {storageId}") - filename = os.path.basename(localFilePath) - fileId = files.get(filename) - print(f"File ID: {fileId}") - match fileId: - case None: - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = ( - f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - ) - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Importing source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - print("Done") - case _: - res = getCrowdinClient().source_files.update_file( - fileId=fileId, - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - ) + addonId = buildVars.addon_info["addon_name"] + filename = addonId + if os.path.splitext(filename)[1] == ".pot": + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + else: + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern, + } + print(f"Exporting source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) + getFiles() + print("Done") -def getFiles() -> dict[str, str]: +def getFiles() -> None: """Gets files from Crowdin, and write them to a json file.""" addonId = buildVars.addon_info["addon_name"] @@ -329,7 +314,6 @@ def getFiles() -> dict[str, str]: dictionary[name] = id with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: json.dump(dictionary, jsonFile, ensure_ascii=False) - return dictionary def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): From 1a1e6fdb476a42ecd5a27b2885ec06c0e3d7b87b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 14 Dec 2025 13:19:12 +0100 Subject: [PATCH 019/100] Update l10nUtil --- _l10n/l10nUtil.py | 64 +++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py index 68725aa..00dab1a 100644 --- a/_l10n/l10nUtil.py +++ b/_l10n/l10nUtil.py @@ -267,6 +267,14 @@ def uploadSourceFile(localFilePath: str): Upload a source file to Crowdin. :param localFilePath: The path to the local file to be uploaded """ + if not os.path.isfile(L10N_FILE): + getFiles() + with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: + files = json.load(jsonFile) + fileId = files.get(localFilePath) + if fileId is None: + files = getFiles() + fileId = files.get(localFilePath) res = getCrowdinClient().storages.add_storage( open(localFilePath, "rb"), ) @@ -274,30 +282,41 @@ def uploadSourceFile(localFilePath: str): raise ValueError("Crowdin storage upload failed") storageId = res["data"]["id"] print(f"Stored with ID {storageId}") - addonId = buildVars.addon_info["addon_name"] - filename = addonId - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Exporting source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - getFiles() - print("Done") + filename = os.path.basename(localFilePath) + fileId = files.get(filename) + print(f"File ID: {fileId}") + match fileId: + case None: + if os.path.splitext(filename)[1] == ".pot": + title = f"{os.path.splitext(filename)[0]} interface" + exportPattern = ( + f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" + ) + else: + title = f"{os.path.splitext(filename)[0]} documentation" + exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" + exportOptions = { + "exportPattern": exportPattern, + } + print(f"Exporting source file {localFilePath} from storage with ID {storageId}") + res = getCrowdinClient().source_files.add_file( + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + name=filename, + title=title, + exportOptions=exportOptions, + ) + print("Done") + case _: + res = getCrowdinClient().source_files.update_file( + fileId=fileId, + storageId=storageId, + projectId=CROWDIN_PROJECT_ID, + ) + -def getFiles() -> None: +def getFiles() -> dict[str, int]: """Gets files from Crowdin, and write them to a json file.""" addonId = buildVars.addon_info["addon_name"] @@ -314,6 +333,7 @@ def getFiles() -> None: dictionary[name] = id with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: json.dump(dictionary, jsonFile, ensure_ascii=False) + return dictionary def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): From d2395b0ddecec7e026d1058f3c4cd4e9122ea23e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 14 Dec 2025 13:20:38 +0100 Subject: [PATCH 020/100] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4ef3da6..b7da396 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -77,10 +77,8 @@ jobs: - name: Generate source files if: ${{ !inputs.update }} run: | - if -f readme.md; then mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - fi uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update md if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} @@ -91,13 +89,13 @@ jobs: if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json and xliff files + - name: Commit and push json id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add hash.json _l10n/l10n.json + git add _l10n/l10n.json if git diff --staged --quiet; then echo "Nothing added to commit." else From e4dafe1492e008f1a2e99f3e23c881135692ab50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 06:00:36 +0100 Subject: [PATCH 021/100] Update readme --- readme.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/readme.md b/readme.md index 05e5f12..82877a0 100644 --- a/readme.md +++ b/readme.md @@ -146,6 +146,20 @@ Note: you must fill out this dictionary if at least one custom symbol dictionary * channel: update channel (do not use this switch unless you know what you are doing). * dev: suitable for development builds, names the add-on according to current date (yyyymmdd) and sets update channel to "dev". + +### Translation workflow + +You can add the documentation and interface messages of your add-on to be translated in Crowdin. + +You need a Crowdin account and an API token with permissions to push to a Crowdin project. +For example, you may want to use this [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons). + +Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. +When you have updated messages or documentation, run the workflow setting update to true (which is the default option). + + + + ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From f76904eeecbb761e6dc4af2657cdd656f46abaee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 17:43:33 +0100 Subject: [PATCH 022/100] Update readme.md Co-authored-by: Sean Budd --- readme.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/readme.md b/readme.md index 82877a0..6a61fcd 100644 --- a/readme.md +++ b/readme.md @@ -157,9 +157,6 @@ For example, you may want to use this [Crowdin project to translate NVDA add-ons Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. When you have updated messages or documentation, run the workflow setting update to true (which is the default option). - - - ### Additional tools The template includes configuration files for use with additional tools such as linters. These include: From aea5ebac891f84320796aa5de9af5f7a51d17852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 17:44:29 +0100 Subject: [PATCH 023/100] Update _l10n/crowdinSync.py Co-authored-by: Sean Budd --- _l10n/crowdinSync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py index 1a56070..0d5ceec 100644 --- a/_l10n/crowdinSync.py +++ b/_l10n/crowdinSync.py @@ -1,6 +1,6 @@ # A part of NonVisual Desktop Access (NVDA) # based on file from https://github.com/jcsteh/osara -# Copyright (C) 2023-2024 NV Access Limited, James Teh +# Copyright (C) 2023-2025 NV Access Limited, James Teh # This file may be used under the terms of the GNU General Public License, version 2 or later. # For more details see: https://www.gnu.org/licenses/gpl-2.0.html From f7ccaf68ff57404cbd071b8cadef024af0f65618 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 19:42:07 +0100 Subject: [PATCH 024/100] Add setOutput.py to separate Python code from yaml file --- .github/workflows/exportAddonToCrowdin.yml | 34 +----------------- .github/workflows/setOutputs.py | 42 ++++++++++++++++++++++ 2 files changed, 43 insertions(+), 33 deletions(-) create mode 100644 .github/workflows/setOutputs.py diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index b7da396..31f4871 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -41,39 +41,7 @@ jobs: uses: astral-sh/setup-uv@v7 - name: Get add-on info id: getAddonInfo - shell: python - run: | - import os, sys, json - sys.path.insert(0, os.getcwd()) - import buildVars, sha256 - addonId = buildVars.addon_info["addon_name"] - readmeFile = os.path.join(os.getcwd(), "readme.md") - i18nSources = sorted(buildVars.i18nSources) - if os.path.isfile(readmeFile): - readmeSha = sha256.sha256_checksum([readmeFile]) - i18nSourcesSha = sha256.sha256_checksum(i18nSources) - hashFile = os.path.join(os.getcwd(), "hash.json") - data = dict() - if os.path.isfile(hashFile): - with open(hashFile, "rt") as f: - data = json.load(f) - shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) - shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - data = dict() - if readmeSha: - data["readmeSha"] = readmeSha - if i18nSourcesSha: - data["i18nSourcesSha"] = i18nSourcesSha - with open(hashFile, "wt", encoding="utf-8") as f: - json.dump(data, f, indent="\t", ensure_ascii=False) - name = 'addonId' - value = addonId - name0 = 'shouldUpdateMd' - value0 = str(shouldUpdateMd).lower() - name1 = 'shouldUpdatePot' - value1 = str(shouldUpdatePot).lower() - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + run: uv run ./.github/workflows/setOutputs.py - name: Generate source files if: ${{ !inputs.update }} run: | diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py new file mode 100644 index 0000000..5e0e5d5 --- /dev/null +++ b/.github/workflows/setOutputs.py @@ -0,0 +1,42 @@ +# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os, sys, json +sys.path.insert(0, os.getcwd()) +import buildVars, sha256 + + +def main(): + addonId = buildVars.addon_info["addon_name"] + readmeFile = os.path.join(os.getcwd(), "readme.md") + i18nSources = sorted(buildVars.i18nSources) + if os.path.isfile(readmeFile): + readmeSha = sha256.sha256_checksum([readmeFile]) + i18nSourcesSha = sha256.sha256_checksum(i18nSources) + hashFile = os.path.join(os.getcwd(), "hash.json") + data = dict() + if os.path.isfile(hashFile): + with open(hashFile, "rt") as f: + data = json.load(f) + shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) + shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + data = dict() + if readmeSha: + data["readmeSha"] = readmeSha + if i18nSourcesSha: + data["i18nSourcesSha"] = i18nSourcesSha + with open(hashFile, "wt", encoding="utf-8") as f: + json.dump(data, f, indent="\t", ensure_ascii=False) + name = 'addonId' + value = addonId + name0 = 'shouldUpdateMd' + value0 = str(shouldUpdateMd).lower() + name1 = 'shouldUpdatePot' + value1 = str(shouldUpdatePot).lower() + with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + + +if __name__ == "__main__": + main() \ No newline at end of file From 0276e2270735ce5915a7bae3318afb9349076c02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:35:17 +0100 Subject: [PATCH 025/100] Remove bad comment --- _l10n/crowdinSync.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py index 0d5ceec..e879bba 100644 --- a/_l10n/crowdinSync.py +++ b/_l10n/crowdinSync.py @@ -79,7 +79,7 @@ def main(): "uploadSourceFile", help="Upload a source file to Crowdin.", ) - # uploadCommand.add_argument("crowdinFileID", type=int, help="The Crowdin file ID.") + uploadCommand.add_argument("localFilePath", help="The path to the local file.") args = parser.parse_args() if args.command == "uploadSourceFile": From 253eb461572334ea9af37a0eda8e14fd9e499fb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:42:13 +0100 Subject: [PATCH 026/100] Reset pyproject to master --- pyproject.toml | 52 +------------------------------------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4673a1c..97189ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,39 +1,3 @@ -[build-system] -requires = ["setuptools~=72.0", "wheel"] -build-backend = "setuptools.build_meta" - -[project] -name = "addonTemplate" -dynamic = ["version"] -description = "NVDA add-on template" -maintainers = [ - {name = "NV Access", email = "info@nvaccess.org"}, -] -requires-python = ">=3.13,<3.14" -classifiers = [ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python :: 3", - "Topic :: Accessibility", -] -readme = "readme.md" -license = {file = "COPYING.TXT"} -dependencies = [ - "crowdin-api-client==1.21.0", - "lxml>=6.0.2", - "markdown>=3.10", - "markdown-link-attr-modifier==0.2.1", - "mdx-gh-links==0.4", - "mdx-truly-sane-lists==1.3", - "nh3==0.2.19", - "requests>=2.32.5", - "scons==4.10.1", -] -[project.urls] -Repository = "https://github.com/nvaccess/addonTemplate" - [tool.ruff] line-length = 110 @@ -56,13 +20,10 @@ include = [ exclude = [ ".git", "__pycache__", - ".venv", - "buildVars.py", ] [tool.ruff.format] indent-style = "tab" -line-ending = "lf" [tool.ruff.lint.mccabe] max-complexity = 15 @@ -72,16 +33,13 @@ ignore = [ # indentation contains tabs "W191", ] -logger-objects = ["logHandler.log"] [tool.ruff.lint.per-file-ignores] -# sconscripts contains many inbuilt functions not recognised by the lint, +# sconstruct contains many inbuilt functions not recognised by the lint, # so ignore F821. "sconstruct" = ["F821"] [tool.pyright] -venvPath = "../nvda/.venv" -venv = "." pythonPlatform = "Windows" typeCheckingMode = "strict" @@ -93,7 +51,6 @@ exclude = [ "sconstruct", ".git", "__pycache__", - ".venv", # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. @@ -102,7 +59,6 @@ exclude = [ # Tell pyright where to load python code from extraPaths = [ "./addon", - "../nvda/source", ] # General config @@ -203,9 +159,3 @@ reportMissingTypeStubs = false # Bad rules # These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. -lint = [ - "ruff==0.8.1", - "pre-commit==4.0.1", - "pyright==1.1.396", -] - From c51e7ad894e92233952dc875def5989c7ad308db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 16 Dec 2025 20:43:29 +0100 Subject: [PATCH 027/100] reset .pre-commit configuration to master --- .pre-commit-config.yaml | 97 +++-------------------------------------- 1 file changed, 6 insertions(+), 91 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 75d507a..dd7a9d6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,92 +1,7 @@ -# Copied from https://github.com/nvaccess/nvda -# https://pre-commit.ci/ -# Configuration for Continuous Integration service -ci: - # Pyright does not seem to work in pre-commit CI - skip: [pyright] - autoupdate_schedule: monthly - autoupdate_commit_msg: "Pre-commit auto-update" - autofix_commit_msg: "Pre-commit auto-fix" - submodules: true - -default_language_version: - python: python3.13 - repos: -- repo: https://github.com/pre-commit-ci/pre-commit-ci-config - rev: v1.6.1 - hooks: - - id: check-pre-commit-ci-config - -- repo: meta - hooks: - # ensures that exclude directives apply to any file in the repository. - - id: check-useless-excludes - # ensures that the configured hooks apply to at least one file in the repository. - - id: check-hooks-apply - -- repo: https://github.com/pre-commit/pre-commit-hooks - rev: v5.0.0 - hooks: - # Prevents commits to certain branches - - id: no-commit-to-branch - args: ["--branch", "main", ] - # Checks that large files have not been added. Default cut-off for "large" files is 500kb. - - id: check-added-large-files - # Checks python syntax - - id: check-ast - # Checks for filenames that will conflict on case insensitive filesystems (the majority of Windows filesystems, most of the time) - - id: check-case-conflict - # Checks for artifacts from resolving merge conflicts. - - id: check-merge-conflict - # Checks Python files for debug statements, such as python's breakpoint function, or those inserted by some IDEs. - - id: debug-statements - # Removes trailing whitespace. - - id: trailing-whitespace - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Ensures all files end in 1 (and only 1) newline. - - id: end-of-file-fixer - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Removes the UTF-8 BOM from files that have it. - # See https://github.com/nvaccess/nvda/blob/master/projectDocs/dev/codingStandards.md#encoding - - id: fix-byte-order-marker - types_or: [python, c, c++, batch, markdown, toml, yaml, powershell] - # Validates TOML files. - - id: check-toml - # Validates YAML files. - - id: check-yaml - # Ensures that links to lines in files under version control point to a particular commit. - - id: check-vcs-permalinks - # Avoids using reserved Windows filenames. - - id: check-illegal-windows-names -- repo: https://github.com/asottile/add-trailing-comma - rev: v3.2.0 - hooks: - # Ruff preserves indent/new-line formatting of function arguments, list items, and similar iterables, - # if a trailing comma is added. - # This adds a trailing comma to args/iterable items in case it was missed. - - id: add-trailing-comma - -- repo: https://github.com/astral-sh/ruff-pre-commit - # Matches Ruff version in pyproject. - rev: v0.12.7 - hooks: - - id: ruff - name: lint with ruff - args: [ --fix ] - - id: ruff-format - name: format with ruff - -- repo: https://github.com/RobertCraigie/pyright-python - rev: v1.1.406 - hooks: - - id: pyright - name: Check types with pyright - additional_dependencies: [ "pyright[nodejs]==1.1.406" ] - -- repo: https://github.com/DavidAnson/markdownlint-cli2 - rev: v0.18.1 - hooks: - - id: markdownlint-cli2 - name: Lint markdown files - args: ["--fix"] + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: check-case-conflict + - id: check-yaml From cd4816c0cbf43d9585dda95812870b8db5096fbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 04:53:36 +0100 Subject: [PATCH 028/100] Remove userAccount variable, since we use markdown, not xliff --- buildVars.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/buildVars.py b/buildVars.py index 770946a..a3fe862 100644 --- a/buildVars.py +++ b/buildVars.py @@ -10,8 +10,6 @@ # which returns whatever is given to it as an argument. from site_scons.site_tools.NVDATool.utils import _ -# The GitHub user account to generate xliff file for translations -userAccount: str | None = None # Add-on information variables addon_info = AddonInfo( # add-on Name/identifier, internal for NVDA From 314220bdc5bbda0e131efd490b7878aa02c9b514 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 05:31:26 +0100 Subject: [PATCH 029/100] Update or add files from scratch depending on existence of hashFile --- .github/workflows/exportAddonToCrowdin.yml | 19 ++++++++----------- .github/workflows/setOutputs.py | 3 +++ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 31f4871..1b8a26c 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -3,12 +3,6 @@ name: Export add-on to Crowdin on: workflow_dispatch: - inputs: - update: - description: 'true to update preexisting sources, false to add them from scratch' - type: boolean - required: false - default: true concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -42,19 +36,22 @@ jobs: - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - - name: Generate source files - if: ${{ !inputs.update }} + - name: Upload md from scratch + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'false' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: update md - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md + - name: Upload pot from scratch + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'false' }} + run: | + uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot - if: ${{ inputs.update && steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index 5e0e5d5..da853bc 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -21,6 +21,9 @@ def main(): data = json.load(f) shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) + else: + shouldUpdateMd = False + shouldUpdatePot = False data = dict() if readmeSha: data["readmeSha"] = readmeSha From f3e8b8d87518e5e579d771d1f0b948eafcf1a5ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 17 Dec 2025 05:42:56 +0100 Subject: [PATCH 030/100] Use addMd and addPotFromScratch outputs --- .github/workflows/exportAddonToCrowdin.yml | 4 ++-- .github/workflows/setOutputs.py | 11 +++++++---- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1b8a26c..4d01c88 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -37,7 +37,7 @@ jobs: id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'false' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md @@ -47,7 +47,7 @@ jobs: mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'false' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} run: | uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index da853bc..9af5839 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -21,9 +21,8 @@ def main(): data = json.load(f) shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - else: - shouldUpdateMd = False - shouldUpdatePot = False + shouldAddMdFromScratch = not os.path.isfile(hashFile) and not shouldUpdateMd + shouldAddPotFromScratch = not os.path.isfile(hashFile) and not shouldUpdatePot data = dict() if readmeSha: data["readmeSha"] = readmeSha @@ -37,8 +36,12 @@ def main(): value0 = str(shouldUpdateMd).lower() name1 = 'shouldUpdatePot' value1 = str(shouldUpdatePot).lower() + name2 = shouldAddMdFromScratch + value2 = str(shouldAddMdFromScratch).lower() + name3 = shouldAddPotFromScratch + value3 = str(shouldAddPotFromScratch).lower() with open(os.environ['GITHUB_OUTPUT'], 'a') as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n") + f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") if __name__ == "__main__": From de4fa152b9172ffa40082f7a8d33414f48152867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:17:09 +0100 Subject: [PATCH 031/100] Update dependencies --- pyproject.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 97189ac..6178665 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,10 @@ +dependencies = [ + "SCons==4.10.1", + "Markdown==3.10", + "ruff==0.14.5", + "pre-commit==4.2.0", + "pyright[nodejs]==1.1.407", +] [tool.ruff] line-length = 110 From 46a105aa032263958169b148688119a9415c0e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:19:03 +0100 Subject: [PATCH 032/100] Update setOutput --- .github/workflows/setOutputs.py | 45 ++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index 9af5839..d8cce3b 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -1,16 +1,26 @@ -# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez +# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez # This file is covered by the GNU General Public License. # See the file COPYING for more details. -import os, sys, json +import os +import sys +import json + sys.path.insert(0, os.getcwd()) -import buildVars, sha256 +import buildVars +import sha256 def main(): addonId = buildVars.addon_info["addon_name"] readmeFile = os.path.join(os.getcwd(), "readme.md") i18nSources = sorted(buildVars.i18nSources) + readmeSha = None + i18nSourcesSha = None + shouldUpdateMd = False + shouldUpdatePot = False + shouldAddMdFromScratch = False + shouldAddPotFromScratch = False if os.path.isfile(readmeFile): readmeSha = sha256.sha256_checksum([readmeFile]) i18nSourcesSha = sha256.sha256_checksum(i18nSources) @@ -19,30 +29,31 @@ def main(): if os.path.isfile(hashFile): with open(hashFile, "rt") as f: data = json.load(f) - shouldUpdateMd = (data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None) and os.path.isfile(readmeFile) - shouldUpdatePot = (data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None) - shouldAddMdFromScratch = not os.path.isfile(hashFile) and not shouldUpdateMd - shouldAddPotFromScratch = not os.path.isfile(hashFile) and not shouldUpdatePot - data = dict() - if readmeSha: + shouldUpdateMd = data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None + shouldUpdatePot = ( + data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None + ) + shouldAddMdFromScratch = data.get("readmeSha") is None + shouldAddPotFromScratch = data.get("i18nSourcesSha") is None + if readmeSha is not None: data["readmeSha"] = readmeSha - if i18nSourcesSha: + if i18nSourcesSha is not None: data["i18nSourcesSha"] = i18nSourcesSha with open(hashFile, "wt", encoding="utf-8") as f: json.dump(data, f, indent="\t", ensure_ascii=False) - name = 'addonId' + name = "addonId" value = addonId - name0 = 'shouldUpdateMd' + name0 = "shouldUpdateMd" value0 = str(shouldUpdateMd).lower() - name1 = 'shouldUpdatePot' + name1 = "shouldUpdatePot" value1 = str(shouldUpdatePot).lower() - name2 = shouldAddMdFromScratch + name2 = "shouldAddMdFromScratch" value2 = str(shouldAddMdFromScratch).lower() - name3 = shouldAddPotFromScratch + name3 = "shouldAddPotFromScratch" value3 = str(shouldAddPotFromScratch).lower() - with open(os.environ['GITHUB_OUTPUT'], 'a') as f: + with open(os.environ["GITHUB_OUTPUT"], "a") as f: f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") if __name__ == "__main__": - main() \ No newline at end of file + main() From 053d4de721cfcaf82bda92a3ea05125c63c3dae3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:21:09 +0100 Subject: [PATCH 033/100] Update workflow --- .github/workflows/exportAddonToCrowdin.yml | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 4d01c88..2367c09 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -17,6 +17,8 @@ jobs: steps: - name: Checkout add-on uses: actions/checkout@v6 + with: + submodules: true - name: "Set up Python" uses: actions/setup-python@v6 with: @@ -27,40 +29,41 @@ jobs: pip install scons markdown sudo apt update sudo apt install gettext + - name: Install the latest version of uv + uses: astral-sh/setup-uv@v7 - name: Build add-on and pot file run: | scons scons pot - - name: Install the latest version of uv - uses: astral-sh/setup-uv@v7 - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | + echo "Yes" mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md + uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} run: | - uv run ./_l10n/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot + uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} run: | - uv run ./_l10n/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json + uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot + - name: Commit and push json id: commit run: | git config --local user.name github-actions git config --local user.email github-actions@github.com git status - git add _l10n/l10n.json + git add *.json if git diff --staged --quiet; then echo "Nothing added to commit." else From 4a3f5a0dfbedeab8ce642b57b0ae0886adddf3ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 20 Dec 2025 22:39:49 +0100 Subject: [PATCH 034/100] Update lock --- uv.lock | 266 +------------------------------------------------------- 1 file changed, 1 insertion(+), 265 deletions(-) diff --git a/uv.lock b/uv.lock index 58c3f26..bda0207 100644 --- a/uv.lock +++ b/uv.lock @@ -1,267 +1,3 @@ version = 1 revision = 3 -requires-python = "==3.13.*" - -[[package]] -name = "addontemplate" -source = { editable = "." } -dependencies = [ - { name = "crowdin-api-client" }, - { name = "lxml" }, - { name = "markdown" }, - { name = "markdown-link-attr-modifier" }, - { name = "mdx-gh-links" }, - { name = "mdx-truly-sane-lists" }, - { name = "nh3" }, - { name = "requests" }, - { name = "scons" }, -] - -[package.metadata] -requires-dist = [ - { name = "crowdin-api-client", specifier = "==1.21.0" }, - { name = "lxml", specifier = ">=6.0.2" }, - { name = "markdown", specifier = ">=3.10" }, - { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, - { name = "mdx-gh-links", specifier = "==0.4" }, - { name = "mdx-truly-sane-lists", specifier = "==1.3" }, - { name = "nh3", specifier = "==0.2.19" }, - { name = "requests", specifier = ">=2.32.5" }, - { name = "scons", specifier = "==4.10.1" }, -] - -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "crowdin-api-client" -version = "1.21.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/39/70/4069412e2e8004a6ad15bf2a3d9085bea50ee932a66ad935285831cf82b4/crowdin_api_client-1.21.0.tar.gz", hash = "sha256:0f957e5de6487a74ac892d524a5e300c1bc971320b67f85ce65741904420d8ec", size = 64729, upload-time = "2025-01-31T15:56:42.179Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/0f/9efc4f6db0b3b97f99015529b5832058ce4f7970d547b23fc04a38d69ddd/crowdin_api_client-1.21.0-py3-none-any.whl", hash = "sha256:85e19557755ebf6a15beda605d25b77de365244d8c636462b0dd8030a6cdfe20", size = 101566, upload-time = "2025-01-31T15:56:40.651Z" }, -] - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - -[[package]] -name = "lxml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, - { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, - { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, - { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, - { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, - { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, - { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, - { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, - { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, - { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, - { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, - { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, - { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, - { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, - { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, -] - -[[package]] -name = "markdown" -version = "3.10" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, -] - -[[package]] -name = "markdown-link-attr-modifier" -version = "0.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, -] - -[[package]] -name = "mdx-gh-links" -version = "0.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, -] - -[[package]] -name = "mdx-truly-sane-lists" -version = "1.3" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, -] - -[[package]] -name = "nh3" -version = "0.2.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1d/32/3b8d8471d006333bac3175ad37402414d985ed3f8650a01a33e0e86b9824/nh3-0.2.19.tar.gz", hash = "sha256:790056b54c068ff8dceb443eaefb696b84beff58cca6c07afd754d17692a4804", size = 17327, upload-time = "2024-11-30T04:05:56.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/1d/cbd75a2313d96cd3903111667d3d07548fb45c8ecf5c315f37a8f6c202fa/nh3-0.2.19-cp313-cp313t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:ec9c8bf86e397cb88c560361f60fdce478b5edb8b93f04ead419b72fbe937ea6", size = 1205181, upload-time = "2024-11-29T05:50:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/f0/30/8e9ec472ce575fa6b98935920c91df637bf9342862bd943745441aec99eb/nh3-0.2.19-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0adf00e2b2026fa10a42537b60d161e516f206781c7515e4e97e09f72a8c5d0", size = 739174, upload-time = "2024-11-29T05:50:10.157Z" }, - { url = "https://files.pythonhosted.org/packages/5c/b5/d1f81c5ec5695464b69d8aa4529ecb5fd872cbfb29f879b4063bb9397da8/nh3-0.2.19-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3805161c4e12088bd74752ba69630e915bc30fe666034f47217a2f16b16efc37", size = 758660, upload-time = "2024-11-29T05:50:13.97Z" }, - { url = "https://files.pythonhosted.org/packages/a0/5e/295a3a069f3b9dc35527eedd7b212f31311ef1f66a0e5f5f0acad6db9456/nh3-0.2.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3dedd7858a21312f7675841529941035a2ac91057db13402c8fe907aa19205a", size = 924377, upload-time = "2024-11-29T05:50:15.716Z" }, - { url = "https://files.pythonhosted.org/packages/71/e2/0f189d5054f22cdfdb16d16a2a41282f411a4c03f8418be47e0480bd5bfd/nh3-0.2.19-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:0b6820fc64f2ff7ef3e7253a093c946a87865c877b3889149a6d21d322ed8dbd", size = 992124, upload-time = "2024-11-29T05:50:17.637Z" }, - { url = "https://files.pythonhosted.org/packages/0d/87/2907edd61a2172527c5322036aa95ce6c18432ff280fc5cf78fe0f934c65/nh3-0.2.19-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:833b3b5f1783ce95834a13030300cea00cbdfd64ea29260d01af9c4821da0aa9", size = 913939, upload-time = "2024-11-29T05:50:20.435Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/e0d3ea0175f28032d7d2bab765250f4e94ef131a7b3293e3df4cb254a5b2/nh3-0.2.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5d4f5e2189861b352b73acb803b5f4bb409c2f36275d22717e27d4e0c217ae55", size = 909051, upload-time = "2024-11-29T05:50:31.116Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/f52cb1d54ba965b7d8bb1c884ca982be31d7f75ad9e7e5817f4af20002b3/nh3-0.2.19-cp313-cp313t-win32.whl", hash = "sha256:2b926f179eb4bce72b651bfdf76f8aa05d167b2b72bc2f3657fd319f40232adc", size = 540566, upload-time = "2024-11-29T05:50:38.437Z" }, - { url = "https://files.pythonhosted.org/packages/70/85/91a66edfab0adbf22468973d8abd4b93c951bbcbbe2121675ee468b912a2/nh3-0.2.19-cp313-cp313t-win_amd64.whl", hash = "sha256:ac536a4b5c073fdadd8f5f4889adabe1cbdae55305366fb870723c96ca7f49c3", size = 542368, upload-time = "2024-11-29T05:50:41.418Z" }, - { url = "https://files.pythonhosted.org/packages/32/9c/f8808cf6683d4852ba8862e25b98aa9116067ddec517938a1b6e8faadb43/nh3-0.2.19-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:c2e3f0d18cc101132fe10ab7ef5c4f41411297e639e23b64b5e888ccaad63f41", size = 1205140, upload-time = "2024-11-29T05:50:54.582Z" }, - { url = "https://files.pythonhosted.org/packages/04/0e/268401d9244a84935342d9f3ba5d22bd7d2fc10cfc7a8f59bde8f6721466/nh3-0.2.19-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11270b16c1b012677e3e2dd166c1aa273388776bf99a3e3677179db5097ee16a", size = 763571, upload-time = "2024-11-29T05:51:03.504Z" }, - { url = "https://files.pythonhosted.org/packages/6f/0d/8be706feb6637d6e5db0eed09fd3f4e1008aee3d5d7161c9973d7aae1d13/nh3-0.2.19-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc483dd8d20f8f8c010783a25a84db3bebeadced92d24d34b40d687f8043ac69", size = 750319, upload-time = "2024-11-29T05:51:05.512Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ce/1f5f9ba0194f6a882e4bda89ae831678e4b68aa3de91e11e2629a1e6a613/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d53a4577b6123ca1d7e8483fad3e13cb7eda28913d516bd0a648c1a473aa21a9", size = 857636, upload-time = "2024-11-29T05:51:06.872Z" }, - { url = "https://files.pythonhosted.org/packages/e9/5d/5661a66f2950879a81fde5fbb6beb650c5647776aaec1a676e6b3ff4b6e5/nh3-0.2.19-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdb20740d24ab9f2a1341458a00a11205294e97e905de060eeab1ceca020c09c", size = 821081, upload-time = "2024-11-29T05:51:08.202Z" }, - { url = "https://files.pythonhosted.org/packages/22/f8/454828f6f21516bf0c8c578e8bc2ab4f045e6b6fe5179602fe4dc2479da6/nh3-0.2.19-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8325d51e47cb5b11f649d55e626d56c76041ba508cd59e0cb1cf687cc7612f1", size = 894452, upload-time = "2024-11-29T05:51:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/2e/5d/36f5b78cbc631cac1c993bdc4608a0fe3148214bdb6d2c1266e228a2686a/nh3-0.2.19-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8eb7affc590e542fa7981ef508cd1644f62176bcd10d4429890fc629b47f0bc", size = 748281, upload-time = "2024-11-29T05:51:29.021Z" }, - { url = "https://files.pythonhosted.org/packages/98/da/d04f5f0e7ee8edab8ceecdbba9f1c614dc8cf07374141ff6ea3b615b3479/nh3-0.2.19-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2eb021804e9df1761abeb844bb86648d77aa118a663c82f50ea04110d87ed707", size = 767109, upload-time = "2024-11-29T05:51:31.222Z" }, - { url = "https://files.pythonhosted.org/packages/0e/5b/1232fb35c7d1182adb7d513fede644a81b5361259749781e6075c40a9125/nh3-0.2.19-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a7b928862daddb29805a1010a0282f77f4b8b238a37b5f76bc6c0d16d930fd22", size = 924295, upload-time = "2024-11-29T05:51:33.078Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fd/ae622d08518fd31360fd87a515700bc09913f2e57e7f010063f2193ea610/nh3-0.2.19-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:ed06ed78f6b69d57463b46a04f68f270605301e69d80756a8adf7519002de57d", size = 992038, upload-time = "2024-11-29T05:51:34.414Z" }, - { url = "https://files.pythonhosted.org/packages/56/78/226577c5e3fe379cb95265aa77736e191d859032c974169e6879c51c156f/nh3-0.2.19-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:df8eac98fec80bd6f5fd0ae27a65de14f1e1a65a76d8e2237eb695f9cd1121d9", size = 913866, upload-time = "2024-11-29T05:51:35.727Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ca/bbd2b2dab31ceae38cfa673861cab81df5ed5be1fe47b6c4f5aa41729aa2/nh3-0.2.19-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00810cd5275f5c3f44b9eb0e521d1a841ee2f8023622de39ffc7d88bd533d8e0", size = 908976, upload-time = "2024-11-29T05:51:43.698Z" }, - { url = "https://files.pythonhosted.org/packages/4c/8f/6452eb1184ad87cdd2cac7ee3ebd67a2aadb554d25572c1778efdf807e1e/nh3-0.2.19-cp38-abi3-win32.whl", hash = "sha256:7e98621856b0a911c21faa5eef8f8ea3e691526c2433f9afc2be713cb6fbdb48", size = 540528, upload-time = "2024-11-29T05:51:45.312Z" }, - { url = "https://files.pythonhosted.org/packages/58/d6/285df10307f16fcce9afbd133b04b4bc7d7f9b84b02f0f724bab30dacdd9/nh3-0.2.19-cp38-abi3-win_amd64.whl", hash = "sha256:75c7cafb840f24430b009f7368945cb5ca88b2b54bb384ebfba495f16bc9c121", size = 542316, upload-time = "2024-11-29T05:52:01.253Z" }, -] - -[[package]] -name = "requests" -version = "2.32.5" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, -] - -[[package]] -name = "scons" -version = "4.10.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, -] - -[[package]] -name = "urllib3" -version = "2.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, -] +requires-python = ">=3.13" From dbe74dcad03276485e697e9ee767ac355e8d7f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:04:21 +0100 Subject: [PATCH 035/100] Verify uv lock --- .pre-commit-config.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c8f5c6..8353208 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,7 +76,10 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff - + - id: uv-lock + name: Verify uv lock file + # Override python interpreter from .python-versions as that is too strict for pre-commit.ci + args: ["-p3.13"] - repo: local hooks: From e717292a628eade7edf31683f8f6d30c1d861186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:06:53 +0100 Subject: [PATCH 036/100] Add uv to dependencies in case this is relevant to verify the lock according to uv version --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6178665..4441e1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,5 @@ dependencies = [ + "uv==0.9.11", "SCons==4.10.1", "Markdown==3.10", "ruff==0.14.5", From c4ed57508a8ca3f859cb77b5aa743b197ed24620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:10:54 +0100 Subject: [PATCH 037/100] Remove debug statement --- .github/workflows/exportAddonToCrowdin.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 2367c09..1096152 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -41,7 +41,6 @@ jobs: - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} run: | - echo "Yes" mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md From befa647e2e7d2b6821a79b2f787ae2fc5675e10e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 21 Dec 2025 11:17:39 +0100 Subject: [PATCH 038/100] Run pre-commit --- .github/workflows/exportAddonToCrowdin.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 1096152..7ca8edb 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -23,18 +23,20 @@ jobs: uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install dependencies + - name: Install gettext run: | - python -m pip install --upgrade pip - pip install scons markdown sudo apt update sudo apt install gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Run pre-commit + run: | + # Ensure uv environment is up to date. + uv run pre-commit run uv-lock --all-files - name: Build add-on and pot file run: | - scons - scons pot + uv run scons + uv run scons pot - name: Get add-on info id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py From 05c816196c41b150cd1007f94ce3830602466abf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:01:01 +0100 Subject: [PATCH 039/100] Update dependencies --- pyproject.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4441e1e..f3054a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,6 @@ dependencies = [ - "uv==0.9.11", "SCons==4.10.1", "Markdown==3.10", - "ruff==0.14.5", - "pre-commit==4.2.0", - "pyright[nodejs]==1.1.407", ] [tool.ruff] line-length = 110 From 9a0f62abbc2be3a9528935c6585ae934bbdf5af1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:29:16 +0100 Subject: [PATCH 040/100] Deleted Pyproject to avoid conflicts --- pyproject.toml | 165 ------------------------------------------------- 1 file changed, 165 deletions(-) delete mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml deleted file mode 100644 index f3054a0..0000000 --- a/pyproject.toml +++ /dev/null @@ -1,165 +0,0 @@ -dependencies = [ - "SCons==4.10.1", - "Markdown==3.10", -] -[tool.ruff] -line-length = 110 - -builtins = [ - # translation lookup - "_", - # translation lookup - "ngettext", - # translation lookup - "pgettext", - # translation lookup - "npgettext", -] - -include = [ - "*.py", - "sconstruct", -] - -exclude = [ - ".git", - "__pycache__", -] - -[tool.ruff.format] -indent-style = "tab" - -[tool.ruff.lint.mccabe] -max-complexity = 15 - -[tool.ruff.lint] -ignore = [ - # indentation contains tabs - "W191", -] - -[tool.ruff.lint.per-file-ignores] -# sconstruct contains many inbuilt functions not recognised by the lint, -# so ignore F821. -"sconstruct" = ["F821"] - -[tool.pyright] -pythonPlatform = "Windows" -typeCheckingMode = "strict" - -include = [ - "**/*.py", -] - -exclude = [ - "sconstruct", - ".git", - "__pycache__", - # When excluding concrete paths relative to a directory, - # not matching multiple folders by name e.g. `__pycache__`, - # paths are relative to the configuration file. -] - -# Tell pyright where to load python code from -extraPaths = [ - "./addon", -] - -# General config -analyzeUnannotatedFunctions = true -deprecateTypingAliases = true - -# Stricter typing -strictParameterNoneValue = true -strictListInference = true -strictDictionaryInference = true -strictSetInference = true - -# Compliant rules -reportAbstractUsage = true -reportArgumentType = true -reportAssertAlwaysTrue = true -reportAssertTypeFailure = true -reportAssignmentType = true -reportAttributeAccessIssue = true -reportCallInDefaultInitializer = true -reportCallIssue = true -reportConstantRedefinition = true -reportDuplicateImport = true -reportFunctionMemberAccess = true -reportGeneralTypeIssues = true -reportImplicitOverride = true -reportImplicitStringConcatenation = true -reportImportCycles = true -reportIncompatibleMethodOverride = true -reportIncompatibleVariableOverride = true -reportIncompleteStub = true -reportInconsistentConstructor = true -reportInconsistentOverload = true -reportIndexIssue = true -reportInvalidStringEscapeSequence = true -reportInvalidStubStatement = true -reportInvalidTypeArguments = true -reportInvalidTypeForm = true -reportInvalidTypeVarUse = true -reportMatchNotExhaustive = true -reportMissingImports = true -reportMissingModuleSource = true -reportMissingParameterType = true -reportMissingSuperCall = true -reportMissingTypeArgument = true -reportNoOverloadImplementation = true -reportOperatorIssue = true -reportOptionalCall = true -reportOptionalContextManager = true -reportOptionalIterable = true -reportOptionalMemberAccess = true -reportOptionalOperand = true -reportOptionalSubscript = true -reportOverlappingOverload = true -reportPossiblyUnboundVariable = true -reportPrivateImportUsage = true -reportPrivateUsage = true -reportPropertyTypeMismatch = true -reportRedeclaration = true -reportReturnType = true -reportSelfClsParameterName = true -reportShadowedImports = true -reportTypeCommentUsage = true -reportTypedDictNotRequiredAccess = true -reportUnboundVariable = true -reportUndefinedVariable = true -reportUnhashable = true -reportUninitializedInstanceVariable = true -reportUnknownArgumentType = true -reportUnknownLambdaType = true -reportUnknownMemberType = true -reportUnknownParameterType = true -reportUnknownVariableType = true -reportUnnecessaryCast = true -reportUnnecessaryComparison = true -reportUnnecessaryContains = true -reportUnnecessaryIsInstance = true -reportUnnecessaryTypeIgnoreComment = true -reportUnsupportedDunderAll = true -reportUntypedBaseClass = true -reportUntypedClassDecorator = true -reportUntypedFunctionDecorator = true -reportUntypedNamedTuple = true -reportUnusedCallResult = true -reportUnusedClass = true -reportUnusedCoroutine = true -reportUnusedExcept = true -reportUnusedExpression = true -reportUnusedFunction = true -reportUnusedImport = true -reportUnusedVariable = true -reportWildcardImportFromLibrary = true - -reportDeprecated = true - -# Can be enabled by generating type stubs for modules via pyright CLI -reportMissingTypeStubs = false - -# Bad rules -# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From 4abd788013e44b6522ce422c7e3678582ae66047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:30:18 +0100 Subject: [PATCH 041/100] Reset pyproject to master --- pyproject.toml | 161 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..97189ac --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,161 @@ +[tool.ruff] +line-length = 110 + +builtins = [ + # translation lookup + "_", + # translation lookup + "ngettext", + # translation lookup + "pgettext", + # translation lookup + "npgettext", +] + +include = [ + "*.py", + "sconstruct", +] + +exclude = [ + ".git", + "__pycache__", +] + +[tool.ruff.format] +indent-style = "tab" + +[tool.ruff.lint.mccabe] +max-complexity = 15 + +[tool.ruff.lint] +ignore = [ + # indentation contains tabs + "W191", +] + +[tool.ruff.lint.per-file-ignores] +# sconstruct contains many inbuilt functions not recognised by the lint, +# so ignore F821. +"sconstruct" = ["F821"] + +[tool.pyright] +pythonPlatform = "Windows" +typeCheckingMode = "strict" + +include = [ + "**/*.py", +] + +exclude = [ + "sconstruct", + ".git", + "__pycache__", + # When excluding concrete paths relative to a directory, + # not matching multiple folders by name e.g. `__pycache__`, + # paths are relative to the configuration file. +] + +# Tell pyright where to load python code from +extraPaths = [ + "./addon", +] + +# General config +analyzeUnannotatedFunctions = true +deprecateTypingAliases = true + +# Stricter typing +strictParameterNoneValue = true +strictListInference = true +strictDictionaryInference = true +strictSetInference = true + +# Compliant rules +reportAbstractUsage = true +reportArgumentType = true +reportAssertAlwaysTrue = true +reportAssertTypeFailure = true +reportAssignmentType = true +reportAttributeAccessIssue = true +reportCallInDefaultInitializer = true +reportCallIssue = true +reportConstantRedefinition = true +reportDuplicateImport = true +reportFunctionMemberAccess = true +reportGeneralTypeIssues = true +reportImplicitOverride = true +reportImplicitStringConcatenation = true +reportImportCycles = true +reportIncompatibleMethodOverride = true +reportIncompatibleVariableOverride = true +reportIncompleteStub = true +reportInconsistentConstructor = true +reportInconsistentOverload = true +reportIndexIssue = true +reportInvalidStringEscapeSequence = true +reportInvalidStubStatement = true +reportInvalidTypeArguments = true +reportInvalidTypeForm = true +reportInvalidTypeVarUse = true +reportMatchNotExhaustive = true +reportMissingImports = true +reportMissingModuleSource = true +reportMissingParameterType = true +reportMissingSuperCall = true +reportMissingTypeArgument = true +reportNoOverloadImplementation = true +reportOperatorIssue = true +reportOptionalCall = true +reportOptionalContextManager = true +reportOptionalIterable = true +reportOptionalMemberAccess = true +reportOptionalOperand = true +reportOptionalSubscript = true +reportOverlappingOverload = true +reportPossiblyUnboundVariable = true +reportPrivateImportUsage = true +reportPrivateUsage = true +reportPropertyTypeMismatch = true +reportRedeclaration = true +reportReturnType = true +reportSelfClsParameterName = true +reportShadowedImports = true +reportTypeCommentUsage = true +reportTypedDictNotRequiredAccess = true +reportUnboundVariable = true +reportUndefinedVariable = true +reportUnhashable = true +reportUninitializedInstanceVariable = true +reportUnknownArgumentType = true +reportUnknownLambdaType = true +reportUnknownMemberType = true +reportUnknownParameterType = true +reportUnknownVariableType = true +reportUnnecessaryCast = true +reportUnnecessaryComparison = true +reportUnnecessaryContains = true +reportUnnecessaryIsInstance = true +reportUnnecessaryTypeIgnoreComment = true +reportUnsupportedDunderAll = true +reportUntypedBaseClass = true +reportUntypedClassDecorator = true +reportUntypedFunctionDecorator = true +reportUntypedNamedTuple = true +reportUnusedCallResult = true +reportUnusedClass = true +reportUnusedCoroutine = true +reportUnusedExcept = true +reportUnusedExpression = true +reportUnusedFunction = true +reportUnusedImport = true +reportUnusedVariable = true +reportWildcardImportFromLibrary = true + +reportDeprecated = true + +# Can be enabled by generating type stubs for modules via pyright CLI +reportMissingTypeStubs = false + +# Bad rules +# These are sorted alphabetically and should be enabled and moved to compliant rules section when resolved. From c25636475dfb6e5367495d06c9b075ee4a1522fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:36:16 +0100 Subject: [PATCH 042/100] Remove _l10n since this will be added as a submodule --- _l10n/crowdinSync.py | 92 ---- _l10n/l10nUtil.py | 951 ------------------------------------- _l10n/markdownTranslate.py | 737 ---------------------------- _l10n/md2html.py | 197 -------- 4 files changed, 1977 deletions(-) delete mode 100644 _l10n/crowdinSync.py delete mode 100644 _l10n/l10nUtil.py delete mode 100644 _l10n/markdownTranslate.py delete mode 100644 _l10n/md2html.py diff --git a/_l10n/crowdinSync.py b/_l10n/crowdinSync.py deleted file mode 100644 index e879bba..0000000 --- a/_l10n/crowdinSync.py +++ /dev/null @@ -1,92 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# based on file from https://github.com/jcsteh/osara -# Copyright (C) 2023-2025 NV Access Limited, James Teh -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - - -import argparse -import os - -import requests - -from l10nUtil import getFiles - -AUTH_TOKEN = os.getenv("crowdinAuthToken", "").strip() -if not AUTH_TOKEN: - raise ValueError("crowdinAuthToken environment variable not set") -PROJECT_ID = os.getenv("crowdinProjectID", "").strip() -if not PROJECT_ID: - raise ValueError("crowdinProjectID environment variable not set") - - -def request( - path: str, - method=requests.get, - headers: dict[str, str] | None = None, - **kwargs, -) -> requests.Response: - if headers is None: - headers = {} - headers["Authorization"] = f"Bearer {AUTH_TOKEN}" - r = method( - f"https://api.crowdin.com/api/v2/{path}", - headers=headers, - **kwargs, - ) - # Convert errors to exceptions, but print the response before raising. - try: - r.raise_for_status() - except requests.exceptions.HTTPError: - print(r.json()) - raise - return r - - -def projectRequest(path: str, **kwargs) -> requests.Response: - return request(f"projects/{PROJECT_ID}/{path}", **kwargs) - - -def uploadSourceFile(localFilePath: str) -> None: - files = getFiles() - fn = os.path.basename(localFilePath) - crowdinFileID = files.get(fn) - print(f"Uploading {localFilePath} to Crowdin temporary storage as {fn}") - with open(localFilePath, "rb") as f: - r = request( - "storages", - method=requests.post, - headers={"Crowdin-API-FileName": fn}, - data=f, - ) - storageID = r.json()["data"]["id"] - print(f"Updating file {crowdinFileID} on Crowdin with storage ID {storageID}") - r = projectRequest( - f"files/{crowdinFileID}", - method=requests.put, - json={"storageId": storageID}, - ) - revisionId = r.json()["data"]["revisionId"] - print(f"Updated to revision {revisionId}") - - -def main(): - parser = argparse.ArgumentParser( - description="Syncs translations with Crowdin.", - ) - commands = parser.add_subparsers(dest="command", required=True) - uploadCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - - uploadCommand.add_argument("localFilePath", help="The path to the local file.") - args = parser.parse_args() - if args.command == "uploadSourceFile": - uploadSourceFile(args.localFilePath) - else: - raise ValueError(f"Unknown command: {args.command}") - - -if __name__ == "__main__": - main() diff --git a/_l10n/l10nUtil.py b/_l10n/l10nUtil.py deleted file mode 100644 index 00dab1a..0000000 --- a/_l10n/l10nUtil.py +++ /dev/null @@ -1,951 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024-2025 NV Access Limited, Noelia Ruiz Martínez -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -import os, sys -sys.path.insert(0, os.getcwd()) - -import crowdin_api as crowdin -import tempfile -import lxml.etree -import os -import shutil -import argparse -import markdownTranslate -import requests -import codecs -import re -import subprocess -import sys -import zipfile -import time -import json - -import buildVars - - -CROWDIN_PROJECT_ID = os.getenv("crowdinProjectID", "").strip() -POLLING_INTERVAL_SECONDS = 5 -EXPORT_TIMEOUT_SECONDS = 60 * 10 # 10 minutes -L10N_FILE = os.path.join(os.path.dirname(__file__), "l10n.json") - - -def fetchCrowdinAuthToken() -> str: - """ - Fetch the Crowdin auth token from the ~/.nvda_crowdin file or prompt the user for it. - If provided by the user, the token will be saved to the ~/.nvda_crowdin file. - :return: The auth token - """ - crowdinAuthToken = os.getenv("crowdinAuthToken", "") - if crowdinAuthToken: - print("Using Crowdin auth token from environment variable.") - return crowdinAuthToken - token_path = os.path.expanduser("~/.nvda_crowdin") - if os.path.exists(token_path): - with open(token_path, "r") as f: - token = f.read().strip() - print("Using auth token from ~/.nvda_crowdin") - return token - print("A Crowdin auth token is required to proceed.") - print("Please visit https://crowdin.com/settings#api-key") - print("Create a personal access token with translations permissions, and enter it below.") - token = input("Enter Crowdin auth token: ").strip() - with open(token_path, "w") as f: - f.write(token) - return token - - -_crowdinClient = None - - -def getCrowdinClient() -> crowdin.CrowdinClient: - """ - Create or fetch the Crowdin client instance. - :return: The Crowdin client - """ - global _crowdinClient - if _crowdinClient is None: - token = fetchCrowdinAuthToken() - _crowdinClient = crowdin.CrowdinClient(project_id=CROWDIN_PROJECT_ID, token=token) - return _crowdinClient - - -def fetchLanguageFromXliff(xliffPath: str, source: bool = False) -> str: - """ - Fetch the language from an xliff file. - This function also prints a message to the console stating the detected language if found, or a warning if not found. - :param xliffPath: Path to the xliff file - :param source: If True, fetch the source language, otherwise fetch the target language - :return: The language code - """ - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - lang = xliffRoot.get("srcLang" if source else "trgLang") - if lang is None: - print(f"Could not detect language for xliff file {xliffPath}, {source=}") - else: - print(f"Detected language {lang} for xliff file {xliffPath}, {source=}") - return lang - - -def preprocessXliff(xliffPath: str, outputPath: str): - """ - Replace corrupt or empty translated segment targets with the source text, - marking the segment again as "initial" state. - This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. - :param xliffPath: Path to the xliff file to be processed - :param outputPath: Path to the resulting xliff file - """ - print(f"Preprocessing xliff file at {xliffPath}") - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - file = xliffRoot.find("./xliff:file", namespaces=namespace) - units = file.findall("./xliff:unit", namespaces=namespace) - segmentCount = 0 - emptyTargetCount = 0 - corruptTargetcount = 0 - for unit in units: - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - print("Warning: No segment element in unit") - continue - source = segment.find("./xliff:source", namespaces=namespace) - if source is None: - print("Warning: No source element in segment") - continue - sourceText = source.text - segmentCount += 1 - target = segment.find("./xliff:target", namespaces=namespace) - if target is None: - continue - targetText = target.text - # Correct empty targets - if not targetText: - emptyTargetCount += 1 - target.text = sourceText - segment.set("state", "initial") - # Correct corrupt target tags - elif targetText in ( - "", - "<target/>", - "", - "<target></target>", - ): - corruptTargetcount += 1 - target.text = sourceText - segment.set("state", "initial") - xliff.write(outputPath, encoding="utf-8") - print( - f"Processed {segmentCount} segments, removing {emptyTargetCount} empty targets, {corruptTargetcount} corrupt targets", - ) - - -def stripXliff(xliffPath: str, outputPath: str, oldXliffPath: str | None = None): - """ - Removes notes and skeleton elements from an xliff file before upload to Crowdin. - Removes empty and corrupt translations. - Removes untranslated segments. - Removes existing translations if an old xliff file is provided. - This function also prints a message to the console stating the number of segments processed and the numbers of empty, corrupt, source and existing translations removed. - :param xliffPath: Path to the xliff file to be stripped - :param outputPath: Path to the resulting xliff file - :param oldXliffPath: Path to the old xliff file containing existing translations that should be also stripped. - """ - print(f"Creating stripped xliff at {outputPath} from {xliffPath}") - namespace = {"xliff": "urn:oasis:names:tc:xliff:document:2.0"} - xliff = lxml.etree.parse(xliffPath) - xliffRoot = xliff.getroot() - if xliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {xliffPath}") - oldXliffRoot = None - if oldXliffPath: - oldXliff = lxml.etree.parse(oldXliffPath) - oldXliffRoot = oldXliff.getroot() - if oldXliffRoot.tag != "{urn:oasis:names:tc:xliff:document:2.0}xliff": - raise ValueError(f"Not an xliff file: {oldXliffPath}") - skeletonNode = xliffRoot.find("./xliff:file/xliff:skeleton", namespaces=namespace) - if skeletonNode is not None: - skeletonNode.getparent().remove(skeletonNode) - file = xliffRoot.find("./xliff:file", namespaces=namespace) - units = file.findall("./xliff:unit", namespaces=namespace) - segmentCount = 0 - untranslatedCount = 0 - emptyCount = 0 - corruptCount = 0 - existingTranslationCount = 0 - for unit in units: - unitID = unit.get("id") - notes = unit.find("./xliff:notes", namespaces=namespace) - if notes is not None: - unit.remove(notes) - segment = unit.find("./xliff:segment", namespaces=namespace) - if segment is None: - print("Warning: No segment element in unit") - continue - segmentCount += 1 - state = segment.get("state") - if state == "initial": - file.remove(unit) - untranslatedCount += 1 - continue - target = segment.find("./xliff:target", namespaces=namespace) - if target is None: - file.remove(unit) - untranslatedCount += 1 - continue - targetText = target.text - if not targetText: - emptyCount += 1 - file.remove(unit) - continue - elif targetText in ( - "", - "<target/>", - "", - "<target></target>", - ): - corruptCount += 1 - file.remove(unit) - continue - if oldXliffRoot: - # Remove existing translations - oldTarget = oldXliffRoot.find( - f"./xliff:file/xliff:unit[@id='{unitID}']/xliff:segment/xliff:target", - namespaces=namespace, - ) - if oldTarget is not None and oldTarget.getparent().get("state") != "initial": - if oldTarget.text == targetText: - file.remove(unit) - existingTranslationCount += 1 - xliff.write(outputPath, encoding="utf-8") - if corruptCount > 0: - print(f"Removed {corruptCount} corrupt translations.") - if emptyCount > 0: - print(f"Removed {emptyCount} empty translations.") - if existingTranslationCount > 0: - print(f"Ignored {existingTranslationCount} existing translations.") - keptTranslations = segmentCount - untranslatedCount - emptyCount - corruptCount - existingTranslationCount - print(f"Added or changed {keptTranslations} translations.") - - -def downloadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): - """ - Download a translation file from Crowdin. - :param crowdinFilePath: The Crowdin file path - :param localFilePath: The path to save the local file - :param language: The language code to download the translation for - """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(crowdinFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(crowdinFilePath) - print(f"Requesting export of {crowdinFilePath} for {language} from Crowdin") - res = getCrowdinClient().translations.export_project_translation( - fileIds=[fileId], - targetLanguageId=language, - ) - if res is None: - raise ValueError("Crowdin export failed") - download_url = res["data"]["url"] - print(f"Downloading from {download_url}") - with open(localFilePath, "wb") as f: - r = requests.get(download_url) - f.write(r.content) - print(f"Saved to {localFilePath}") - - -def uploadSourceFile(localFilePath: str): - """ - Upload a source file to Crowdin. - :param localFilePath: The path to the local file to be uploaded - """ - if not os.path.isfile(L10N_FILE): - getFiles() - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(localFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(localFilePath) - res = getCrowdinClient().storages.add_storage( - open(localFilePath, "rb"), - ) - if res is None: - raise ValueError("Crowdin storage upload failed") - storageId = res["data"]["id"] - print(f"Stored with ID {storageId}") - filename = os.path.basename(localFilePath) - fileId = files.get(filename) - print(f"File ID: {fileId}") - match fileId: - case None: - if os.path.splitext(filename)[1] == ".pot": - title = f"{os.path.splitext(filename)[0]} interface" - exportPattern = ( - f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{os.path.splitext(filename)[0]}.po" - ) - else: - title = f"{os.path.splitext(filename)[0]} documentation" - exportPattern = f"/{os.path.splitext(filename)[0]}/%two_letters_code%/{filename}" - exportOptions = { - "exportPattern": exportPattern, - } - print(f"Exporting source file {localFilePath} from storage with ID {storageId}") - res = getCrowdinClient().source_files.add_file( - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - name=filename, - title=title, - exportOptions=exportOptions, - ) - print("Done") - case _: - res = getCrowdinClient().source_files.update_file( - fileId=fileId, - storageId=storageId, - projectId=CROWDIN_PROJECT_ID, - ) - - - -def getFiles() -> dict[str, int]: - """Gets files from Crowdin, and write them to a json file.""" - - addonId = buildVars.addon_info["addon_name"] - - res = getCrowdinClient().source_files.list_files(CROWDIN_PROJECT_ID, filter=addonId) - if res is None: - raise ValueError("Getting files from Crowdin failed") - dictionary = dict() - data = res["data"] - for file in data: - fileInfo = file["data"] - name = fileInfo["name"] - id = fileInfo["id"] - dictionary[name] = id - with open(L10N_FILE, "w", encoding="utf-8") as jsonFile: - json.dump(dictionary, jsonFile, ensure_ascii=False) - return dictionary - - -def uploadTranslationFile(crowdinFilePath: str, localFilePath: str, language: str): - """ - Upload a translation file to Crowdin. - :param crowdinFilePath: The Crowdin file path - :param localFilePath: The path to the local file to be uploaded - :param language: The language code to upload the translation for - """ - with open(L10N_FILE, "r", encoding="utf-8") as jsonFile: - files = json.load(jsonFile) - fileId = files.get(crowdinFilePath) - if fileId is None: - files = getFiles() - fileId = files.get(crowdinFilePath) - print(f"Uploading {localFilePath} to Crowdin") - res = getCrowdinClient().storages.add_storage( - open(localFilePath, "rb"), - ) - if res is None: - raise ValueError("Crowdin storage upload failed") - storageId = res["data"]["id"] - print(f"Stored with ID {storageId}") - print(f"Importing translation for {crowdinFilePath} in {language} from storage with ID {storageId}") - res = getCrowdinClient().translations.upload_translation( - fileId=fileId, - languageId=language, - storageId=storageId, - autoApproveImported=True, - importEqSuggestions=True, - ) - print("Done") - - -def exportTranslations(outputDir: str, language: str | None = None): - """ - Export translation files from Crowdin as a bundle. - :param outputDir: Directory to save translation files. - :param language: The language code to export (e.g., 'es', 'fr', 'de'). - If None, exports all languages. - """ - - # Create output directory if it doesn't exist - os.makedirs(outputDir, exist_ok=True) - - client = getCrowdinClient() - - requestData = { - "skipUntranslatedStrings": False, - "skipUntranslatedFiles": True, - "exportApprovedOnly": False, - } - - if language is not None: - requestData["targetLanguageIds"] = [language] - - if language is None: - print("Requesting export of all translations from Crowdin...") - else: - print(f"Requesting export of all translations for language: {language}") - build_res = client.translations.build_project_translation(request_data=requestData) - - if language is None: - zip_filename = "translations.zip" - else: - zip_filename = f"translations_{language}.zip" - - if build_res is None: - raise ValueError("Failed to start translation build") - - build_id = build_res["data"]["id"] - print(f"Build started with ID: {build_id}") - - # Wait for the build to complete - print("Waiting for build to complete...") - while True: - status_res = client.translations.check_project_build_status(build_id) - if status_res is None: - raise ValueError("Failed to check build status") - - status = status_res["data"]["status"] - progress = status_res["data"]["progress"] - print(f"Build status: {status} ({progress}%)") - - if status == "finished": - break - elif status == "failed": - raise ValueError("Translation build failed") - - time.sleep(POLLING_INTERVAL_SECONDS) - - # Download the completed build - print("Downloading translations archive...") - download_res = client.translations.download_project_translations(build_id) - if download_res is None: - raise ValueError("Failed to get download URL") - - download_url = download_res["data"]["url"] - print(f"Downloading from {download_url}") - - # Download and extract the ZIP file - zip_path = os.path.join(outputDir, zip_filename) - response = requests.get(download_url, stream=True, timeout=EXPORT_TIMEOUT_SECONDS) - response.raise_for_status() - - with open(zip_path, "wb") as f: - for chunk in response.iter_content(chunk_size=8192): - f.write(chunk) - - print(f"Archive saved to {zip_path}") - print("Extracting translations...") - - with zipfile.ZipFile(zip_path, "r") as zip_ref: - zip_ref.extractall(outputDir) - - # Remove the zip file - os.remove(zip_path) - - if language is None: - print(f"\nExport complete! All translations extracted to '{outputDir}' directory.") - else: - print(f"\nExport complete! All {language} translations extracted to '{outputDir}' directory.") - - -class _PoChecker: - """Checks a po file for errors not detected by msgfmt. - This first runs msgfmt to check for syntax errors. - It then checks for mismatched Python percent and brace interpolations. - Construct an instance and call the L{check} method. - """ - - FUZZY = "#, fuzzy" - MSGID = "msgid" - MSGID_PLURAL = "msgid_plural" - MSGSTR = "msgstr" - - def __init__(self, po: str): - """Constructor. - :param po: The path to the po file to check. - """ - self._poPath = po - with codecs.open(po, "r", "utf-8") as file: - self._poContent = file.readlines() - self._string: str | None = None - - self.alerts: list[str] = [] - """List of error and warning messages found in the po file.""" - - self.hasSyntaxError: bool = False - """Whether there is a syntax error in the po file.""" - - self.warningCount: int = 0 - """Number of warnings found.""" - - self.errorCount: int = 0 - """Number of errors found.""" - - def _addToString(self, line: list[str], startingCommand: str | None = None) -> None: - """Helper function to add a line to the current string. - :param line: The line to add. - :param startingCommand: The command that started this string, if any. - This is used to determine whether to strip the command and quotes. - """ - if startingCommand: - # Strip the command and the quotes. - self._string = line[len(startingCommand) + 2 : -1] - else: - # Strip the quotes. - self._string += line[1:-1] - - def _finishString(self) -> str: - """Helper function to finish the current string. - :return: The finished string. - """ - string = self._string - self._string = None - return string - - def _messageAlert(self, alert: str, isError: bool = True) -> None: - """Helper function to add an alert about a message. - :param alert: The alert message. - :param isError: Whether this is an error or a warning. - """ - if self._fuzzy: - # Fuzzy messages don't get used, so this shouldn't be considered an error. - isError = False - if isError: - self.errorCount += 1 - else: - self.warningCount += 1 - if self._fuzzy: - msgType = "Fuzzy message" - else: - msgType = "Message" - self.alerts.append( - f"{msgType} starting on line {self._messageLineNum}\n" - f'Original: "{self._msgid}"\n' - f'Translated: "{self._msgstr[-1]}"\n' - f"{'ERROR' if isError else 'WARNING'}: {alert}", - ) - - @property - def MSGFMT_PATH(self) -> str: - try: - # When running from source, miscDeps is the sibling of parent this script. - _MSGFMT = os.path.join(os.path.dirname(__file__), "..", "miscDeps", "tools", "msgfmt.exe") - except NameError: - # When running from a frozen executable, __file__ is not defined. - # In this case, we use the distribution path. - # When running from a distribution, source/l10nUtil.py is built to l10nUtil.exe. - # miscDeps is the sibling of this script in the distribution. - _MSGFMT = os.path.join(sys.prefix, "miscDeps", "tools", "msgfmt.exe") - - if not os.path.exists(_MSGFMT): - raise FileNotFoundError( - "msgfmt executable not found. " - "Please ensure that miscDeps/tools/msgfmt.exe exists in the source tree or distribution.", - ) - return _MSGFMT - - def _checkSyntax(self) -> None: - """Check the syntax of the po file using msgfmt. - This will set the hasSyntaxError attribute to True if there is a syntax error. - """ - - result = subprocess.run( - (self.MSGFMT_PATH, "-o", "-", self._poPath), - stdout=subprocess.DEVNULL, - stderr=subprocess.PIPE, - text=True, # Ensures stderr is a text stream - ) - if result.returncode != 0: - output = result.stderr.rstrip().replace("\r\n", "\n") - self.alerts.append(output) - self.hasSyntaxError = True - self.errorCount = 1 - - def _checkMessages(self) -> None: - command = None - self._msgid = None - self._msgid_plural = None - self._msgstr = None - nextFuzzy = False - self._fuzzy = False - for lineNum, line in enumerate(self._poContent, 1): - line = line.strip() - if line.startswith(self.FUZZY): - nextFuzzy = True - continue - elif line.startswith(self.MSGID) and not line.startswith(self.MSGID_PLURAL): - # New message. - if self._msgstr is not None: - self._msgstr[-1] = self._finishString() - # Check the message we just handled. - self._checkMessage() - command = self.MSGID - start = command - self._messageLineNum = lineNum - self._fuzzy = nextFuzzy - nextFuzzy = False - elif line.startswith(self.MSGID_PLURAL): - self._msgid = self._finishString() - command = self.MSGID_PLURAL - start = command - elif line.startswith(self.MSGSTR): - self._handleMsgStrReaching(lastCommand=command) - command = self.MSGSTR - start = line[: line.find(" ")] - elif line.startswith('"'): - # Continuing a string. - start = None - else: - # This line isn't of interest. - continue - self._addToString(line, startingCommand=start) - if command == self.MSGSTR: - # Handle the last message. - self._msgstr[-1] = self._finishString() - self._checkMessage() - - def _handleMsgStrReaching(self, lastCommand: str) -> None: - """Helper function used by _checkMessages to handle the required processing when reaching a line - starting with "msgstr". - :param lastCommand: the current command just before the msgstr line is reached. - """ - - # Finish the string of the last command and check the message if it was an msgstr - if lastCommand == self.MSGID: - self._msgid = self._finishString() - elif lastCommand == self.MSGID_PLURAL: - self._msgid_plural = self._finishString() - elif lastCommand == self.MSGSTR: - self._msgstr[-1] = self._finishString() - self._checkMessage() - else: - raise RuntimeError(f"Unexpected command before line {self._messageLineNum}: {lastCommand}") - - # For first msgstr create the msgstr list - if lastCommand != self.MSGSTR: - self._msgstr = [] - - # Initiate the string for the current msgstr - self._msgstr.append("") - - def check(self) -> bool: - """Check the file. - Once this returns, you can call getReport to obtain a report. - This method should not be called more than once. - :return: True if the file is okay, False if there were problems. - """ - self._checkSyntax() - if self.alerts: - return False - self._checkMessages() - if self.alerts: - return False - return True - - # e.g. %s %d %10.2f %-5s (but not %%) or %%(name)s %(name)d - RE_UNNAMED_PERCENT = re.compile( - # Does not include optional mapping key, as that's handled by a different regex - r""" - (?:(?<=%%)|(? tuple[list[str], set[str], set[str]]: - """Get the percent and brace interpolations in a string. - :param text: The text to check. - :return: A tuple of a list and two sets: - - unnamed percent interpolations (e.g. %s, %d) - - named percent interpolations (e.g. %(name)s) - - brace format interpolations (e.g. {name}, {name:format}) - """ - unnamedPercent = self.RE_UNNAMED_PERCENT.findall(text) - namedPercent = set(self.RE_NAMED_PERCENT.findall(text)) - formats = set() - for m in self.RE_FORMAT.finditer(text): - if not m.group(1): - self._messageAlert( - "Unspecified positional argument in brace format", - # Skip as error as many of these had been introduced in the source .po files. - # These should be fixed in the source .po files to add names to instances of "{}". - # This causes issues where the order of the arguments change in the string. - # e.g. "Character: {}\nReplacement: {}" being translated to "Replacement: {}\nCharacter: {}" - # will result in the expected interpolation being in the wrong place. - # This should be changed isError=True. - isError=False, - ) - formats.add(m.group(0)) - return unnamedPercent, namedPercent, formats - - def _formatInterpolations( - self, - unnamedPercent: list[str], - namedPercent: set[str], - formats: set[str], - ) -> str: - """Format the interpolations for display in an error message. - :param unnamedPercent: The unnamed percent interpolations. - :param namedPercent: The named percent interpolations. - :param formats: The brace format interpolations. - """ - out: list[str] = [] - if unnamedPercent: - out.append(f"unnamed percent interpolations in this order: {unnamedPercent}") - if namedPercent: - out.append(f"these named percent interpolations: {namedPercent}") - if formats: - out.append(f"these brace format interpolations: {formats}") - if not out: - return "no interpolations" - return "\n\tAnd ".join(out) - - def _checkMessage(self) -> None: - idUnnamedPercent, idNamedPercent, idFormats = self._getInterpolations(self._msgid) - if not self._msgstr[-1]: - return - strUnnamedPercent, strNamedPercent, strFormats = self._getInterpolations(self._msgstr[-1]) - error = False - alerts = [] - if idUnnamedPercent != strUnnamedPercent: - if idUnnamedPercent: - alerts.append("unnamed percent interpolations differ") - error = True - else: - alerts.append("unexpected presence of unnamed percent interpolations") - if idNamedPercent - strNamedPercent: - alerts.append("missing named percent interpolation") - error = True - if strNamedPercent - idNamedPercent: - if idNamedPercent: - alerts.append("extra named percent interpolation") - error = True - else: - alerts.append("unexpected presence of named percent interpolations") - if idFormats - strFormats: - alerts.append("missing brace format interpolation") - error = True - if strFormats - idFormats: - if idFormats: - alerts.append("extra brace format interpolation") - error = True - else: - alerts.append("unexpected presence of brace format interpolations") - if alerts: - self._messageAlert( - f"{', '.join(alerts)}\n" - f"Expected: {self._formatInterpolations(idUnnamedPercent, idNamedPercent, idFormats)}\n" - f"Got: {self._formatInterpolations(strUnnamedPercent, strNamedPercent, strFormats)}", - isError=error, - ) - - def getReport(self) -> str | None: - """Get a text report about any errors or warnings. - :return: The text or None if there were no problems. - """ - if not self.alerts: - return None - report = f"File {self._poPath}: " - if self.hasSyntaxError: - report += "syntax error" - else: - if self.errorCount: - msg = "error" if self.errorCount == 1 else "errors" - report += f"{self.errorCount} {msg}" - if self.warningCount: - if self.errorCount: - report += ", " - msg = "warning" if self.warningCount == 1 else "warnings" - report += f"{self.warningCount} {msg}" - report += "\n\n" + "\n\n".join(self.alerts) - return report - - -def checkPo(poFilePath: str) -> tuple[bool, str | None]: - """Check a po file for errors. - :param poFilePath: The path to the po file to check. - :return: - True if the file is okay or has warnings, False if there were fatal errors. - A report about the errors or warnings found, or None if there were no problems. - """ - c = _PoChecker(poFilePath) - report = None - if not c.check(): - report = c.getReport() - if report: - report = report.encode("cp1252", errors="backslashreplace").decode( - "utf-8", - errors="backslashreplace", - ) - return not bool(c.errorCount), report - - -def main(): - args = argparse.ArgumentParser() - commands = args.add_subparsers(title="commands", dest="command", required=True) - command_checkPo = commands.add_parser("checkPo", help="Check po files") - # Allow entering arbitrary po file paths, not just those in the source tree - command_checkPo.add_argument( - "poFilePaths", - help="Paths to the po file to check", - nargs="+", - ) - command_xliff2md = commands.add_parser("xliff2md", help="Convert xliff to markdown") - command_xliff2md.add_argument( - "-u", - "--untranslated", - help="Produce the untranslated markdown file", - action="store_true", - default=False, - ) - command_xliff2md.add_argument("xliffPath", help="Path to the xliff file") - command_xliff2md.add_argument("mdPath", help="Path to the resulting markdown file") - downloadTranslationFileCommand = commands.add_parser( - "downloadTranslationFile", - help="Download a translation file from Crowdin.", - ) - downloadTranslationFileCommand.add_argument( - "language", - help="The language code to download the translation for.", - ) - downloadTranslationFileCommand.add_argument( - "crowdinFilePath", - help="The Crowdin file path", - ) - downloadTranslationFileCommand.add_argument( - "localFilePath", - nargs="?", - default=None, - help="The path to save the local file. If not provided, the Crowdin file path will be used.", - ) - uploadTranslationFileCommand = commands.add_parser( - "uploadTranslationFile", - help="Upload a translation file to Crowdin.", - ) - uploadTranslationFileCommand.add_argument( - "-o", - "--old", - help="Path to the old unchanged xliff file. If provided, only new or changed translations will be uploaded.", - default=None, - ) - uploadTranslationFileCommand.add_argument( - "language", - help="The language code to upload the translation for.", - ) - uploadTranslationFileCommand.add_argument( - "crowdinFilePath", - help="The Crowdin file path", - ) - uploadTranslationFileCommand.add_argument( - "localFilePath", - nargs="?", - default=None, - help="The path to the local file to be uploaded. If not provided, the Crowdin file path will be used.", - ) - uploadSourceFileCommand = commands.add_parser( - "uploadSourceFile", - help="Upload a source file to Crowdin.", - ) - uploadSourceFileCommand.add_argument( - "-f", - "--localFilePath", - help="The local path to the file.", - ) - exportTranslationsCommand = commands.add_parser( - "exportTranslations", - help="Export translation files from Crowdin as a bundle. If no language is specified, exports all languages.", - ) - exportTranslationsCommand.add_argument( - "-o", - "--output", - help="Directory to save translation files", - required=True, - ) - exportTranslationsCommand.add_argument( - "-l", - "--language", - help="Language code to export (e.g., 'es', 'fr', 'de'). If not specified, exports all languages.", - default=None, - ) - - args = args.parse_args() - match args.command: - case "xliff2md": - markdownTranslate.generateMarkdown( - xliffPath=args.xliffPath, - outputPath=args.mdPath, - translated=not args.untranslated, - ) - case "uploadSourceFile": - uploadSourceFile(args.localFilePath) - case "getFiles": - getFiles() - case "downloadTranslationFile": - localFilePath = args.localFilePath or args.crowdinFilePath - downloadTranslationFile(args.crowdinFilePath, localFilePath, args.language) - if args.crowdinFilePath.endswith(".xliff"): - preprocessXliff(localFilePath, localFilePath) - elif localFilePath.endswith(".po"): - success, report = checkPo(localFilePath) - if report: - print(report) - if not success: - print(f"\nWarning: Po file {localFilePath} has fatal errors.") - case "checkPo": - poFilePaths = args.poFilePaths - badFilePaths: list[str] = [] - for poFilePath in poFilePaths: - success, report = checkPo(poFilePath) - if report: - print(report) - if not success: - badFilePaths.append(poFilePath) - if badFilePaths: - print(f"\nOne or more po files had fatal errors: {', '.join(badFilePaths)}") - sys.exit(1) - case "uploadTranslationFile": - localFilePath = args.localFilePath or args.crowdinFilePath - needsDelete = False - if args.crowdinFilePath.endswith(".xliff"): - tmp = tempfile.NamedTemporaryFile(suffix=".xliff", delete=False, mode="w") - tmp.close() - shutil.copyfile(localFilePath, tmp.name) - stripXliff(tmp.name, tmp.name, args.old) - localFilePath = tmp.name - needsDelete = True - elif localFilePath.endswith(".po"): - success, report = checkPo(localFilePath) - if report: - print(report) - if not success: - print(f"\nPo file {localFilePath} has errors. Upload aborted.") - sys.exit(1) - uploadTranslationFile(args.crowdinFilePath, localFilePath, args.language) - if needsDelete: - os.remove(localFilePath) - case "exportTranslations": - exportTranslations(args.output, args.language) - case _: - raise ValueError(f"Unknown command {args.command}") - - -if __name__ == "__main__": - main() diff --git a/_l10n/markdownTranslate.py b/_l10n/markdownTranslate.py deleted file mode 100644 index fa9a186..0000000 --- a/_l10n/markdownTranslate.py +++ /dev/null @@ -1,737 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2024 NV Access Limited. -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -from typing import Generator -import tempfile -import os -import sys -sys.path.insert(0, os.getcwd()) -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 - -import buildVars - -RAW_GITHUB_REPO_URL = f"https://raw.githubusercontent.com/{buildVars.userAccount}/{buildVars.addon_info["addon_name"]}" -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.DOTALL) -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-]+)\)(.*)$") - - -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("\\", "/") - return f"{RAW_GITHUB_REPO_URL}/{commitID}/{relativePath}" - - -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 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="")) - mdDiff = difflib.ndiff(origMdFile.readlines(), newMdFile.readlines()) - 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(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(), 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(file1.readlines(), 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/_l10n/md2html.py b/_l10n/md2html.py deleted file mode 100644 index 01acab0..0000000 --- a/_l10n/md2html.py +++ /dev/null @@ -1,197 +0,0 @@ -# A part of NonVisual Desktop Access (NVDA) -# Copyright (C) 2023-2024 NV Access Limited -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - -import argparse -from copy import deepcopy -import io -import re -import shutil - -DEFAULT_EXTENSIONS = frozenset( - { - # Supports tables, HTML mixed with markdown, code blocks, custom attributes and more - "markdown.extensions.extra", - # Allows TOC with [TOC]" - "markdown.extensions.toc", - # Makes list behaviour better, including 2 space indents by default - "mdx_truly_sane_lists", - # External links will open in a new tab, and title will be set to the link text - "markdown_link_attr_modifier", - # Adds links to GitHub authors, issues and PRs - "mdx_gh_links", - }, -) - -EXTENSIONS_CONFIG = { - "markdown_link_attr_modifier": { - "new_tab": "external_only", - "auto_title": "on", - }, - "mdx_gh_links": { - "user": "nvaccess", - "repo": "nvda", - }, -} - -RTL_LANG_CODES = frozenset({"ar", "fa", "he"}) - -HTML_HEADERS = """ - - - - -{title} - - - -{extraStylesheet} - - -""".strip() - - -def _getTitle(mdBuffer: io.StringIO, isKeyCommands: bool = False) -> str: - if isKeyCommands: - TITLE_RE = re.compile(r"^$") - # Make next read at start of buffer - mdBuffer.seek(0) - for line in mdBuffer.readlines(): - match = TITLE_RE.match(line.strip()) - if match: - return match.group(1) - - raise ValueError("No KC:title command found in userGuide.md") - - else: - # Make next read at start of buffer - mdBuffer.seek(0) - # Remove heading hashes and trailing whitespace to get the tab title - title = mdBuffer.readline().strip().lstrip("# ") - - return title - - -def _createAttributeFilter() -> dict[str, set[str]]: - # Create attribute filter exceptions for HTML sanitization - import nh3 - - allowedAttributes: dict[str, set[str]] = deepcopy(nh3.ALLOWED_ATTRIBUTES) - - attributesWithAnchors = {"h1", "h2", "h3", "h4", "h5", "h6", "td"} - attributesWithClass = {"div", "span", "a", "th", "td"} - - # Allow IDs for anchors - for attr in attributesWithAnchors: - if attr not in allowedAttributes: - allowedAttributes[attr] = set() - allowedAttributes[attr].add("id") - - # Allow class for styling - for attr in attributesWithClass: - if attr not in allowedAttributes: - allowedAttributes[attr] = set() - allowedAttributes[attr].add("class") - - # link rel and target is set by markdown_link_attr_modifier - allowedAttributes["a"].update({"rel", "target"}) - - return allowedAttributes - - -ALLOWED_ATTRIBUTES = _createAttributeFilter() - - -def _generateSanitizedHTML(md: str, isKeyCommands: bool = False) -> str: - import markdown - import nh3 - - extensions = set(DEFAULT_EXTENSIONS) - if isKeyCommands: - from keyCommandsDoc import KeyCommandsExtension - - extensions.add(KeyCommandsExtension()) - - htmlOutput = markdown.markdown( - text=md, - extensions=extensions, - extension_configs=EXTENSIONS_CONFIG, - ) - - # Sanitize html output from markdown to prevent XSS from translators - htmlOutput = nh3.clean( - htmlOutput, - attributes=ALLOWED_ATTRIBUTES, - # link rel is handled by markdown_link_attr_modifier - link_rel=None, - # Keep key command comments and similar - strip_comments=False, - ) - - return htmlOutput - - -def main(source: str, dest: str, lang: str = "en", docType: str | None = None): - print(f"Converting {docType or 'document'} ({lang=}) at {source} to {dest}") - isUserGuide = docType == "userGuide" - isDevGuide = docType == "developerGuide" - isChanges = docType == "changes" - isKeyCommands = docType == "keyCommands" - if docType and not any([isUserGuide, isDevGuide, isChanges, isKeyCommands]): - raise ValueError(f"Unknown docType {docType}") - with open(source, "r", encoding="utf-8") as mdFile: - mdStr = mdFile.read() - - with io.StringIO() as mdBuffer: - mdBuffer.write(mdStr) - title = _getTitle(mdBuffer, isKeyCommands) - - if isUserGuide or isDevGuide: - extraStylesheet = '' - elif isChanges or isKeyCommands: - extraStylesheet = "" - else: - raise ValueError(f"Unknown target type for {dest}") - - htmlBuffer = io.StringIO() - htmlBuffer.write( - HTML_HEADERS.format( - lang=lang, - dir="rtl" if lang in RTL_LANG_CODES else "ltr", - title=title, - extraStylesheet=extraStylesheet, - ), - ) - - htmlOutput = _generateSanitizedHTML(mdStr, isKeyCommands) - # Make next write append at end of buffer - htmlBuffer.seek(0, io.SEEK_END) - htmlBuffer.write(htmlOutput) - - # Make next write append at end of buffer - htmlBuffer.seek(0, io.SEEK_END) - htmlBuffer.write("\n\n\n") - - with open(dest, "w", encoding="utf-8") as targetFile: - # Make next read at start of buffer - htmlBuffer.seek(0) - shutil.copyfileobj(htmlBuffer, targetFile) - - htmlBuffer.close() - - -if __name__ == "__main__": - args = argparse.ArgumentParser() - args.add_argument("-l", "--lang", help="Language code", action="store", default="en") - args.add_argument( - "-t", - "--docType", - help="Type of document", - action="store", - choices=["userGuide", "developerGuide", "changes", "keyCommands"], - ) - args.add_argument("source", help="Path to the markdown file") - args.add_argument("dest", help="Path to the resulting html file") - args = args.parse_args() - main(source=args.source, dest=args.dest, lang=args.lang, docType=args.docType) From 0505a3b4ccb7d72fca8f1341839d39b99c35a8ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 05:38:01 +0100 Subject: [PATCH 043/100] Don't run pre-commit since it requires a different token to access hooks --- .github/workflows/exportAddonToCrowdin.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 7ca8edb..c16481a 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -29,10 +29,6 @@ jobs: sudo apt install gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Run pre-commit - run: | - # Ensure uv environment is up to date. - uv run pre-commit run uv-lock --all-files - name: Build add-on and pot file run: | uv run scons From fd2554b8a0af52fda7af32e5538dcab220a60c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:24:10 +0100 Subject: [PATCH 044/100] Merge translations into branch --- .github/workflows/exportAddonToCrowdin.yml | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index c16481a..04067a0 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -9,6 +9,7 @@ concurrency: env: crowdinProjectID: ${{ vars.CROWDIN_ID }} crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} + downloadTranslationsBranch: l10n jobs: build: runs-on: ubuntu-latest @@ -67,3 +68,36 @@ jobs: git commit -m "Update Crowdin file ids and hashes" git push fi + - name: Download translations from Crowdin + run: | + uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n + mkdir -p addon/locale + mkdir -p addon/doc + for dir in _addonL10n/${{ steps.getAddonInfo.outputs.addonId }}/*; do + echo "Processing: $dir" + if [ -d "$dir" ]; then + langCode=$(basename "$dir") + poFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.po" + if [ -f "$poFile" ]; then + mkdir -p "addon/locale/$langCode/LC_MESSAGES" + echo "Moving $poFile to addon/locale/$langCode/LC_MESSAGES/nvda.po" + mv "$poFile" "addon/locale/$langCode/LC_MESSAGES/nvda.po" + fi + mdFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.md" + if [ -f "$mdFile" ]; then + mkdir -p "addon/doc/$langCode" + echo "Moving $mdFile to addon/doc/$langCode/readme.md" + mv "$mdFile" "addon/doc/$langCode/readme.md" + fi + else + echo "Skipping invalid directory: $dir" + fi + done + git add addon/locale addon/doc + if git diff --staged --quiet; then + echo "Nothing added to commit." + else + git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" + git checkout -b ${{ env.downloadTranslationsBranch }} + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + fi From b30f46fb414f5848b95cb755bf1e6c13069284eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:38:52 +0100 Subject: [PATCH 045/100] Add project id without using vars --- .github/workflows/exportAddonToCrowdin.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 04067a0..7ba4dca 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -7,7 +7,7 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - crowdinProjectID: ${{ vars.CROWDIN_ID }} + crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: From 70293c84189a5e9f51e459defea26964cee06b12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:42:24 +0100 Subject: [PATCH 046/100] Schedule workflow --- .github/workflows/exportAddonToCrowdin.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/exportAddonToCrowdin.yml index 7ba4dca..eea43a7 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/exportAddonToCrowdin.yml @@ -3,6 +3,9 @@ name: Export add-on to Crowdin on: workflow_dispatch: + schedule: + # Every Monday at 00:00 UTC + - cron: '0 0 * * 1' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true From da09c8c445285ea419dbab16a70e755f6087748c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:43:26 +0100 Subject: [PATCH 047/100] Rename workflow --- .github/workflows/{exportAddonToCrowdin.yml => crowdinL10n.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{exportAddonToCrowdin.yml => crowdinL10n.yml} (99%) diff --git a/.github/workflows/exportAddonToCrowdin.yml b/.github/workflows/crowdinL10n.yml similarity index 99% rename from .github/workflows/exportAddonToCrowdin.yml rename to .github/workflows/crowdinL10n.yml index eea43a7..46f6271 100644 --- a/.github/workflows/exportAddonToCrowdin.yml +++ b/.github/workflows/crowdinL10n.yml @@ -1,4 +1,4 @@ -name: Export add-on to Crowdin +name: Crowdin l10n on: From 5c52f33da968846e9f5fcf16faa37f33455bd316 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 20:56:48 +0100 Subject: [PATCH 048/100] Create PR --- .github/workflows/crowdinL10n.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 46f6271..d3f507c 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -103,4 +103,7 @@ jobs: git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + gh pr create --base ${{ env.GITHUB_DEFAULT_BRANCH }} --head ${{ env.downloadTranslationsBranch }} + --title "Update tracked translations from Crowdin" \ + --body "This pull request updates translations to languages being tracked from Crowdin." fi From d0d5e0393aaa0fb8d5394113169c8acd59cc6228 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 22:04:39 +0100 Subject: [PATCH 049/100] =?UTF-8?q?Don't=20create=20a=20PR=20since=20this?= =?UTF-8?q?=20n=C2=A1may=20need=20a=20personal=20access=20token?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/crowdinL10n.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index d3f507c..e7a1460 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -13,11 +13,13 @@ env: crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n + GH_TOKEN: ${{ github.token }} jobs: build: runs-on: ubuntu-latest permissions: contents: write + pull-requests: write steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -103,7 +105,5 @@ jobs: git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - gh pr create --base ${{ env.GITHUB_DEFAULT_BRANCH }} --head ${{ env.downloadTranslationsBranch }} - --title "Update tracked translations from Crowdin" \ - --body "This pull request updates translations to languages being tracked from Crowdin." fi + From b40f94ac87d8fe1198b2ad8a8909f312c20ee613 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 22 Dec 2025 22:05:30 +0100 Subject: [PATCH 050/100] Update removing permissions for PR --- .github/workflows/crowdinL10n.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index e7a1460..cbd0df9 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -13,13 +13,11 @@ env: crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n - GH_TOKEN: ${{ github.token }} jobs: build: runs-on: ubuntu-latest permissions: contents: write - pull-requests: write steps: - name: Checkout add-on uses: actions/checkout@v6 From cb7807e096d093b47c220d5559d151315e7449f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 06:52:33 +0100 Subject: [PATCH 051/100] Update Python version compatible with ubuntu-latest --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 24ee5b1..2c45fe3 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13 +3.13.11 From 1449a01c80a4395536f2c745aea860b03330beeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 07:01:38 +0100 Subject: [PATCH 052/100] Add dry-run --- .github/workflows/crowdinL10n.yml | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index cbd0df9..1b966b0 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,6 +3,12 @@ name: Crowdin l10n on: workflow_dispatch: + inputs: + dry-run: + description: 'Dry run mode (skip Crowdin upload/download)' + required: false + type: boolean + default: false schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' @@ -41,21 +47,21 @@ jobs: id: getAddonInfo run: uv run ./.github/workflows/setOutputs.py - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - name: update md - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' && inputs.dry-run != true }} run: | mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' && inputs.dry-run != true }} run: | uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Update pot - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' }} + if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' && inputs.dry-run != true }} run: | uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json @@ -72,6 +78,7 @@ jobs: git push fi - name: Download translations from Crowdin + if: ${{ inputs.dry-run != true }} run: | uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n mkdir -p addon/locale @@ -104,4 +111,3 @@ jobs: git checkout -b ${{ env.downloadTranslationsBranch }} git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} fi - From 697d04857511423e90932d1a854f2b735da0fe13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 28 Dec 2025 09:37:10 +0100 Subject: [PATCH 053/100] Optimize workflow to test with act and docker locally --- .github/workflows/crowdinL10n.yml | 13 ++++++---- .gitignore | 3 +++ sha256.py | 41 +++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 sha256.py diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 1b966b0..5f75489 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -35,17 +35,19 @@ jobs: python-version-file: ".python-version" - name: Install gettext run: | - sudo apt update - sudo apt install gettext + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 + - name: Install dependencies + run: uv pip install --system scons markdown - name: Build add-on and pot file run: | - uv run scons - uv run scons pot + uv run --with scons --with markdown scons + uv run --with scons --with markdown scons pot - name: Get add-on info id: getAddonInfo - run: uv run ./.github/workflows/setOutputs.py + run: uv run --with scons --with markdown ./.github/workflows/setOutputs.py - name: Upload md from scratch if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} run: | @@ -65,6 +67,7 @@ jobs: run: | uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - name: Commit and push json + if: ${{ inputs.dry-run != true }} id: commit run: | git config --local user.name github-actions diff --git a/.gitignore b/.gitignore index 1750f2c..e915e3a 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,6 @@ addon/locale/*/*.ini *.pyc *.nvda-addon .sconsign.dblite + +# act configuration +.actrc diff --git a/sha256.py b/sha256.py new file mode 100644 index 0000000..51c903b --- /dev/null +++ b/sha256.py @@ -0,0 +1,41 @@ +# Copyright (C) 2020-2025 NV Access Limited, Noelia Ruiz Martínez +# This file may be used under the terms of the GNU General Public License, version 2 or later. +# For more details see: https://www.gnu.org/licenses/gpl-2.0.html + +import argparse +import hashlib +import typing + +#: The read size for each chunk read from the file, prevents memory overuse with large files. +BLOCK_SIZE = 65536 + + +def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = BLOCK_SIZE): + """ + :param binaryReadModeFiles: A list of files (mode=='rb'). Calculate its sha256 hash. + :param blockSize: The size of each read. + :return: The Sha256 hex digest. + """ + sha256 = hashlib.sha256() + for f in binaryReadModeFiles: + with open(f, "rb") as file: + assert file.readable() and file.mode == "rb" + for block in iter(lambda: file.read(blockSize), b""): + sha256.update(block) + return sha256.hexdigest() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + type=argparse.FileType("rb"), + dest="file", + help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", + ) + args = parser.parse_args() + checksum = sha256_checksum(args.file) + print(f"Sha256:\t {checksum}") + + +if __name__ == "__main__": + main() From 8c9247b1f547386c4badf9c43609341c45f2054b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 30 Dec 2025 05:42:28 +0100 Subject: [PATCH 054/100] Update uv.lock --- uv.lock | 463 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 462 insertions(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index bda0207..e0def29 100644 --- a/uv.lock +++ b/uv.lock @@ -1,3 +1,464 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "crowdin-api-client" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "requests" }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, + { name = "lxml", specifier = "==6.0.2" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "requests", specifier = "==2.32.5" }, + { name = "ruff", specifier = "==0.14.5" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.9.11" }, +] + +[[package]] +name = "certifi" +version = "2025.11.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +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" } +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" }, +] + +[[package]] +name = "filelock" +version = "3.20.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/fd/4e8f0540608977aea078bf6d79f128e0e2c2bba8af1acf775c30baa70460/lxml-6.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9b33d21594afab46f37ae58dfadd06636f154923c4e8a4d754b0127554eb2e77", size = 8648494, upload-time = "2025-09-22T04:01:54.242Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f4/2a94a3d3dfd6c6b433501b8d470a1960a20ecce93245cf2db1706adf6c19/lxml-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c8963287d7a4c5c9a432ff487c52e9c5618667179c18a204bdedb27310f022f", size = 4661146, upload-time = "2025-09-22T04:01:56.282Z" }, + { url = "https://files.pythonhosted.org/packages/25/2e/4efa677fa6b322013035d38016f6ae859d06cac67437ca7dc708a6af7028/lxml-6.0.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1941354d92699fb5ffe6ed7b32f9649e43c2feb4b97205f75866f7d21aa91452", size = 4946932, upload-time = "2025-09-22T04:01:58.989Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0f/526e78a6d38d109fdbaa5049c62e1d32fdd70c75fb61c4eadf3045d3d124/lxml-6.0.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb2f6ca0ae2d983ded09357b84af659c954722bbf04dea98030064996d156048", size = 5100060, upload-time = "2025-09-22T04:02:00.812Z" }, + { url = "https://files.pythonhosted.org/packages/81/76/99de58d81fa702cc0ea7edae4f4640416c2062813a00ff24bd70ac1d9c9b/lxml-6.0.2-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb2a12d704f180a902d7fa778c6d71f36ceb7b0d317f34cdc76a5d05aa1dd1df", size = 5019000, upload-time = "2025-09-22T04:02:02.671Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/9e57d25482bc9a9882cb0037fdb9cc18f4b79d85df94fa9d2a89562f1d25/lxml-6.0.2-cp313-cp313-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6ec0e3f745021bfed19c456647f0298d60a24c9ff86d9d051f52b509663feeb1", size = 5348496, upload-time = "2025-09-22T04:02:04.904Z" }, + { url = "https://files.pythonhosted.org/packages/a6/8e/cb99bd0b83ccc3e8f0f528e9aa1f7a9965dfec08c617070c5db8d63a87ce/lxml-6.0.2-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:846ae9a12d54e368933b9759052d6206a9e8b250291109c48e350c1f1f49d916", size = 5643779, upload-time = "2025-09-22T04:02:06.689Z" }, + { url = "https://files.pythonhosted.org/packages/d0/34/9e591954939276bb679b73773836c6684c22e56d05980e31d52a9a8deb18/lxml-6.0.2-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef9266d2aa545d7374938fb5c484531ef5a2ec7f2d573e62f8ce722c735685fd", size = 5244072, upload-time = "2025-09-22T04:02:08.587Z" }, + { url = "https://files.pythonhosted.org/packages/8d/27/b29ff065f9aaca443ee377aff699714fcbffb371b4fce5ac4ca759e436d5/lxml-6.0.2-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:4077b7c79f31755df33b795dc12119cb557a0106bfdab0d2c2d97bd3cf3dffa6", size = 4718675, upload-time = "2025-09-22T04:02:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f756f9c2cd27caa1a6ef8c32ae47aadea697f5c2c6d07b0dae133c244fbe/lxml-6.0.2-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7c5d5e5f1081955358533be077166ee97ed2571d6a66bdba6ec2f609a715d1a", size = 5255171, upload-time = "2025-09-22T04:02:12.631Z" }, + { url = "https://files.pythonhosted.org/packages/61/46/bb85ea42d2cb1bd8395484fd72f38e3389611aa496ac7772da9205bbda0e/lxml-6.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8f8d0cbd0674ee89863a523e6994ac25fd5be9c8486acfc3e5ccea679bad2679", size = 5057175, upload-time = "2025-09-22T04:02:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/443fc476dcc8e41577f0af70458c50fe299a97bb6b7505bb1ae09aa7f9ac/lxml-6.0.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2cbcbf6d6e924c28f04a43f3b6f6e272312a090f269eff68a2982e13e5d57659", size = 4785688, upload-time = "2025-09-22T04:02:16.957Z" }, + { url = "https://files.pythonhosted.org/packages/48/78/6ef0b359d45bb9697bc5a626e1992fa5d27aa3f8004b137b2314793b50a0/lxml-6.0.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:dfb874cfa53340009af6bdd7e54ebc0d21012a60a4e65d927c2e477112e63484", size = 5660655, upload-time = "2025-09-22T04:02:18.815Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ea/e1d33808f386bc1339d08c0dcada6e4712d4ed8e93fcad5f057070b7988a/lxml-6.0.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb8dae0b6b8b7f9e96c26fdd8121522ce5de9bb5538010870bd538683d30e9a2", size = 5247695, upload-time = "2025-09-22T04:02:20.593Z" }, + { url = "https://files.pythonhosted.org/packages/4f/47/eba75dfd8183673725255247a603b4ad606f4ae657b60c6c145b381697da/lxml-6.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:358d9adae670b63e95bc59747c72f4dc97c9ec58881d4627fe0120da0f90d314", size = 5269841, upload-time = "2025-09-22T04:02:22.489Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/5c5e2b8577bc936e219becb2e98cdb1aca14a4921a12995b9d0c523502ae/lxml-6.0.2-cp313-cp313-win32.whl", hash = "sha256:e8cd2415f372e7e5a789d743d133ae474290a90b9023197fd78f32e2dc6873e2", size = 3610700, upload-time = "2025-09-22T04:02:24.465Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0a/4643ccc6bb8b143e9f9640aa54e38255f9d3b45feb2cbe7ae2ca47e8782e/lxml-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:b30d46379644fbfc3ab81f8f82ae4de55179414651f110a1514f0b1f8f6cb2d7", size = 4010347, upload-time = "2025-09-22T04:02:26.286Z" }, + { url = "https://files.pythonhosted.org/packages/31/ef/dcf1d29c3f530577f61e5fe2f1bd72929acf779953668a8a47a479ae6f26/lxml-6.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:13dcecc9946dca97b11b7c40d29fba63b55ab4170d3c0cf8c0c164343b9bfdcf", size = 3671248, upload-time = "2025-09-22T04:02:27.918Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/35/d806c2ca66072e36dc340ccdbeb2af7e4f1b5bcc33f1481f00ceed476708/nodejs_wheel_binaries-24.12.0.tar.gz", hash = "sha256:f1b50aa25375e264697dec04b232474906b997c2630c8f499f4caf3692938435", size = 8058, upload-time = "2025-12-11T21:12:26.856Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/3b/9d6f044319cd5b1e98f07c41e2465b58cadc1c9c04a74c891578f3be6cb5/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_arm64.whl", hash = "sha256:7564ddea0a87eff34e9b3ef71764cc2a476a8f09a5cccfddc4691148b0a47338", size = 55125859, upload-time = "2025-12-11T21:11:58.132Z" }, + { url = "https://files.pythonhosted.org/packages/48/a5/f5722bf15c014e2f476d7c76bce3d55c341d19122d8a5d86454db32a61a4/nodejs_wheel_binaries-24.12.0-py2.py3-none-macosx_13_0_x86_64.whl", hash = "sha256:8ff929c4669e64613ceb07f5bbd758d528c3563820c75d5de3249eb452c0c0ab", size = 55309035, upload-time = "2025-12-11T21:12:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/a9/61/68d39a6f1b5df67805969fd2829ba7e80696c9af19537856ec912050a2be/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:6ebacefa8891bc456ad3655e6bce0af7e20ba08662f79d9109986faeb703fd6f", size = 59661017, upload-time = "2025-12-11T21:12:05.268Z" }, + { url = "https://files.pythonhosted.org/packages/16/a1/31aad16f55a5e44ca7ea62d1367fc69f4b6e1dba67f58a0a41d0ed854540/nodejs_wheel_binaries-24.12.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:3292649a03682ccbfa47f7b04d3e4240e8c46ef04dc941b708f20e4e6a764f75", size = 60159770, upload-time = "2025-12-11T21:12:08.696Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5e/b7c569aa1862690ca4d4daf3a64cafa1ea6ce667a9e3ae3918c56e127d9b/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7fb83df312955ea355ba7f8cbd7055c477249a131d3cb43b60e4aeb8f8c730b1", size = 61653561, upload-time = "2025-12-11T21:12:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/71/87/567f58d7ba69ff0208be849b37be0f2c2e99c69e49334edd45ff44f00043/nodejs_wheel_binaries-24.12.0-py2.py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:2473c819448fedd7b036dde236b09f3c8bbf39fbbd0c1068790a0498800f498b", size = 62238331, upload-time = "2025-12-11T21:12:16.143Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9d/c6492188ce8de90093c6755a4a63bb6b2b4efb17094cb4f9a9a49c73ed3b/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_amd64.whl", hash = "sha256:2090d59f75a68079fabc9b86b14df8238b9aecb9577966dc142ce2a23a32e9bb", size = 41342076, upload-time = "2025-12-11T21:12:20.618Z" }, + { url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uv" +version = "0.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/08/3bf76403ea7c22feef634849137fab10b28ab5ba5bbf08a53390763d5448/uv-0.9.11.tar.gz", hash = "sha256:605a7a57f508aabd029fc0c5ef5c60a556f8c50d32e194f1a300a9f4e87f18d4", size = 3744387, upload-time = "2025-11-20T23:20:00.95Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/26/8f917e9faddd9cb49abcbc8c7dac5343b0f61d04c6ac36873d2a324fee1a/uv-0.9.11-py3-none-linux_armv6l.whl", hash = "sha256:803f85cf25ab7f1fca10fe2e40a1b9f5b1d48efc25efd6651ba3c9668db6a19e", size = 20787588, upload-time = "2025-11-20T23:18:53.738Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1f/eafd39c719ddee19fc25884f68c1a7e736c0fca63c1cbef925caf8ebd739/uv-0.9.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6a31b0bd4eaec59bf97816aefbcd75cae4fcc8875c4b19ef1846b7bff3d67c70", size = 19922144, upload-time = "2025-11-20T23:18:57.569Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f3/6b9fac39e5b65fa47dba872dcf171f1470490cd645343e8334f20f73885b/uv-0.9.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:48548a23fb5a103b8955dfafff7d79d21112b8e25ce5ff25e3468dc541b20e83", size = 18380643, upload-time = "2025-11-20T23:19:01.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9a/d4080e95950a4fc6fdf20d67b9a43ffb8e3d6d6b7c8dda460ae73ddbecd9/uv-0.9.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cb680948e678590b5960744af2ecea6f2c0307dbb74ac44daf5c00e84ad8c09f", size = 20310262, upload-time = "2025-11-20T23:19:04.914Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/86d9c881bd6accf2b766f7193b50e9d5815f2b34806191d90ea24967965e/uv-0.9.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ef1982295e5aaf909a9668d6fb6abfc5089666c699f585a36f3a67f1a22916a", size = 20392988, upload-time = "2025-11-20T23:19:08.258Z" }, + { url = "https://files.pythonhosted.org/packages/a3/1d/6a227b7ca1829442c1419ba1db856d176b6e0861f9bf9355a8790a5d02b5/uv-0.9.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92ff773aa4193148019533c55382c2f9c661824bbf0c2e03f12aeefc800ede57", size = 21394892, upload-time = "2025-11-20T23:19:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/5a/8f/df45b8409923121de8c4081c9d6d8ba3273eaa450645e1e542d83179c7b5/uv-0.9.11-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:70137a46675bbecf3a8b43d292a61767f1b944156af3d0f8d5986292bd86f6cf", size = 22987735, upload-time = "2025-11-20T23:19:16.27Z" }, + { url = "https://files.pythonhosted.org/packages/89/51/bbf3248a619c9f502d310a11362da5ed72c312d354fb8f9667c5aa3be9dd/uv-0.9.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b5af9117bab6c4b3a1cacb0cddfb3cd540d0adfb13c7b8a9a318873cf2d07e52", size = 22617321, upload-time = "2025-11-20T23:19:20.1Z" }, + { url = "https://files.pythonhosted.org/packages/3f/cd/a158ec989c5433dc86ebd9fea800f2aed24255b84ab65b6d7407251e5e31/uv-0.9.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8cc86940d9b3a425575f25dc45247be2fb31f7fed7bf3394ae9daadd466e5b80", size = 21615712, upload-time = "2025-11-20T23:19:23.71Z" }, + { url = "https://files.pythonhosted.org/packages/73/da/2597becbc0fcbb59608d38fda5db79969e76dedf5b072f0e8564c8f0628b/uv-0.9.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e97906ca1b90dac91c23af20e282e2e37c8eb80c3721898733928a295f2defda", size = 21661022, upload-time = "2025-11-20T23:19:27.385Z" }, + { url = "https://files.pythonhosted.org/packages/52/66/9b8f3b3529b23c2a6f5b9612da70ea53117935ec999757b4f1d640f63d63/uv-0.9.11-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d901269e1db72abc974ba61d37be6e56532e104922329e0b553d9df07ba224be", size = 20440548, upload-time = "2025-11-20T23:19:31.051Z" }, + { url = "https://files.pythonhosted.org/packages/72/b2/683afdb83e96dd966eb7cf3688af56a1b826c8bc1e8182fb10ec35b3e391/uv-0.9.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8abfb7d4b136de3e92dd239ea9a51d4b7bbb970dc1b33bec84d08facf82b9a6e", size = 21493758, upload-time = "2025-11-20T23:19:34.688Z" }, + { url = "https://files.pythonhosted.org/packages/f4/00/99848bc9834aab104fa74aa1a60b1ca478dee824d2e4aacb15af85673572/uv-0.9.11-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:1f8afc13b3b94bce1e72514c598d41623387b2b61b68d7dbce9a01a0d8874860", size = 20332324, upload-time = "2025-11-20T23:19:38.376Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/8cfd1bb1cc5d768cb334f976ba2686c6327e4ac91c16b8469b284956d4d9/uv-0.9.11-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7d414cfa410f1850a244d87255f98d06ca61cc13d82f6413c4f03e9e0c9effc7", size = 20845062, upload-time = "2025-11-20T23:19:42.006Z" }, + { url = "https://files.pythonhosted.org/packages/a0/42/43f66bfc621464dabe9cfe3cbf69cddc36464da56ab786c94fc9ccf99cc7/uv-0.9.11-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:edc14143d0ba086a7da4b737a77746bb36bc00e3d26466f180ea99e3bf795171", size = 21857559, upload-time = "2025-11-20T23:19:46.026Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/bfd41bf087522601c724d712c3727aeb62f51b1f67c4ab86a078c3947525/uv-0.9.11-py3-none-win32.whl", hash = "sha256:af5fd91eecaa04b4799f553c726307200f45da844d5c7c5880d64db4debdd5dc", size = 19639246, upload-time = "2025-11-20T23:19:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2f/d51c02627de68a7ca5b82f0a5d61d753beee3fe696366d1a1c5d5e40cd58/uv-0.9.11-py3-none-win_amd64.whl", hash = "sha256:c65a024ad98547e32168f3a52360fe73ff39cd609a8fb9dd2509aac91483cfc8", size = 21626822, upload-time = "2025-11-20T23:19:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/af/d8/e07e866ee328d3c9f27a6d57a018d8330f47be95ef4654a178779c968a66/uv-0.9.11-py3-none-win_arm64.whl", hash = "sha256:4907a696c745703542ed2559bdf5380b92c8b1d4bf290ebfed45bf9a2a2c6690", size = 20046856, upload-time = "2025-11-20T23:19:58.517Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] From 6089057b2feba44cd46a5d3433590940932228ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 25 Mar 2026 06:39:04 +0100 Subject: [PATCH 055/100] Add workflow call to build the add-on, so it can be reused in other workflows --- .github/workflows/build_addon.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build_addon.yml b/.github/workflows/build_addon.yml index 9e0fa64..0ed7a64 100644 --- a/.github/workflows/build_addon.yml +++ b/.github/workflows/build_addon.yml @@ -10,6 +10,7 @@ on: branches: [ main, master ] workflow_dispatch: + workflow_call: jobs: build: From d8154a9dd511ba9661b7dc781c364dea57cd491c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Wed, 25 Mar 2026 16:39:20 +0100 Subject: [PATCH 056/100] Update workflow for testing --- .github/workflows/crowdinL10n.yml | 120 +++++++++++++----------------- 1 file changed, 50 insertions(+), 70 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 5f75489..a875a6a 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -16,12 +16,14 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - crowdinProjectID: 780748 crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: build: - runs-on: ubuntu-latest + uses: ./.github/workflows/build_addon.yml + crowdinSync: + needs: build + runs-on: windows-latest permissions: contents: write steps: @@ -33,84 +35,62 @@ jobs: uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install gettext - run: | - sudo apt-get update -qq - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq gettext - name: Install the latest version of uv uses: astral-sh/setup-uv@v7 - - name: Install dependencies - run: uv pip install --system scons markdown - - name: Build add-on and pot file - run: | - uv run --with scons --with markdown scons - uv run --with scons --with markdown scons pot - name: Get add-on info id: getAddonInfo - run: uv run --with scons --with markdown ./.github/workflows/setOutputs.py - - name: Upload md from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' && inputs.dry-run != true }} - run: | - mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md - - name: update md - if: ${{ steps.getAddonInfo.outputs.shouldUpdateMd == 'true' && inputs.dry-run != true }} + shell: pwsh run: | - mv readme.md ${{ steps.getAddonInfo.outputs.addonId }}.md - uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.md - - name: Upload pot from scratch - if: ${{ steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' && inputs.dry-run != true }} + uv sync + uv run ./.github/workflows/setOutputs.py + - name: Download l10nUtil from nvdal10n + if: ${{ inputs.dry-run != true }} run: | - uv run ./_l10n/source/l10nUtil.py uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Update pot - if: ${{ steps.getAddonInfo.outputs.shouldUpdatePot == 'true' && inputs.dry-run != true }} + gh run download --repo nvaccess/nvdal10n --name l10nUtil --dir _l10n + - name: Upload md + if: ${{ (steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdateMd == 'true') && inputs.dry-run != true }} run: | - uv run ./_l10n/source/crowdinSync.py uploadSourceFile ${{ steps.getAddonInfo.outputs.addonId }}.pot - - name: Commit and push json + Move-Item -Path readme.md -Destination "${{ steps.getAddonInfo.outputs.addonId }}.md" + uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md -c addon + - name: Download pot from build job if: ${{ inputs.dry-run != true }} - id: commit + uses: actions/download-artifact@v8 + with: + name: packaged_addon + - name: Upload pot + if: ${{ (steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdatePot == 'true') && inputs.dry-run != true }} run: | - git config --local user.name github-actions - git config --local user.email github-actions@github.com - git status - git add *.json - if git diff --staged --quiet; then - echo "Nothing added to commit." - else - git commit -m "Update Crowdin file ids and hashes" - git push - fi + uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot -c addon - name: Download translations from Crowdin if: ${{ inputs.dry-run != true }} run: | - uv run _l10n/source/l10nUtil.py exportTranslations -o _addonL10n - mkdir -p addon/locale - mkdir -p addon/doc - for dir in _addonL10n/${{ steps.getAddonInfo.outputs.addonId }}/*; do - echo "Processing: $dir" - if [ -d "$dir" ]; then - langCode=$(basename "$dir") - poFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.po" - if [ -f "$poFile" ]; then - mkdir -p "addon/locale/$langCode/LC_MESSAGES" - echo "Moving $poFile to addon/locale/$langCode/LC_MESSAGES/nvda.po" - mv "$poFile" "addon/locale/$langCode/LC_MESSAGES/nvda.po" - fi - mdFile="$dir/${{ steps.getAddonInfo.outputs.addonId }}.md" - if [ -f "$mdFile" ]; then - mkdir -p "addon/doc/$langCode" - echo "Moving $mdFile to addon/doc/$langCode/readme.md" - mv "$mdFile" "addon/doc/$langCode/readme.md" - fi - else - echo "Skipping invalid directory: $dir" - fi - done + uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon + New-Item -ItemType Directory -Force -Path addon/locale | Out-Null + New-Item -ItemType Directory -Force -Path addon/doc | Out-Null + foreach ($dir in Get-ChildItem -Path "_addonL10n/${{ steps.getAddonInfo.outputs.addonId }}" -Directory) { + Write-Host "Processing: $($dir.FullName)" + $langCode = $dir.Name + $poFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.po" + if (Test-Path -PathType Leaf $poFile) { + $targetDir = "addon/locale/$langCode/LC_MESSAGES" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Write-Host "Moving $poFile to $targetDir/nvda.po" + Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + } + $mdFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.md" + if (Test-Path -PathType Leaf $mdFile) { + $targetDir = "addon/doc/$langCode" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + Write-Host "Moving $mdFile to $targetDir/readme.md" + Move-Item -Path $mdFile -Destination "$targetDir/readme.md" -Force + } + } git add addon/locale addon/doc - if git diff --staged --quiet; then - echo "Nothing added to commit." - else - git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" - git checkout -b ${{ env.downloadTranslationsBranch }} - git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - fi + $diff = git diff --staged --quiet + if ($LASTEXITCODE -eq 0) { + Write-Host "Nothing added to commit." + } else { + git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" + git checkout -b ${{ env.downloadTranslationsBranch }} + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} + } From e699abc415a5e65ef0ac4eeaf2655efa1d773bb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 18 Apr 2026 07:04:49 +0200 Subject: [PATCH 057/100] Improve workflow to download files from Crowdin --- .github/workflows/crowdinL10n.yml | 37 +++++-------------------------- 1 file changed, 6 insertions(+), 31 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a875a6a..a9e23db 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,12 +3,6 @@ name: Crowdin l10n on: workflow_dispatch: - inputs: - dry-run: - description: 'Dry run mode (skip Crowdin upload/download)' - required: false - type: boolean - default: false schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' @@ -19,10 +13,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n jobs: - build: - uses: ./.github/workflows/build_addon.yml crowdinSync: - needs: build runs-on: windows-latest permissions: contents: write @@ -44,25 +35,10 @@ jobs: uv sync uv run ./.github/workflows/setOutputs.py - name: Download l10nUtil from nvdal10n - if: ${{ inputs.dry-run != true }} run: | - gh run download --repo nvaccess/nvdal10n --name l10nUtil --dir _l10n - - name: Upload md - if: ${{ (steps.getAddonInfo.outputs.shouldAddMdFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdateMd == 'true') && inputs.dry-run != true }} - run: | - Move-Item -Path readme.md -Destination "${{ steps.getAddonInfo.outputs.addonId }}.md" - uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.md -c addon - - name: Download pot from build job - if: ${{ inputs.dry-run != true }} - uses: actions/download-artifact@v8 - with: - name: packaged_addon - - name: Upload pot - if: ${{ (steps.getAddonInfo.outputs.shouldAddPotFromScratch == 'true' || steps.getAddonInfo.outputs.shouldUpdatePot == 'true') && inputs.dry-run != true }} - run: | - uv run ./_l10n/l10nUtil.exe uploadSourceFile --localFilePath=${{ steps.getAddonInfo.outputs.addonId }}.pot -c addon + # Download the latest release asset matching the pattern from the specified repository + gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - name: Download translations from Crowdin - if: ${{ inputs.dry-run != true }} run: | uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon New-Item -ItemType Directory -Force -Path addon/locale | Out-Null @@ -77,12 +53,11 @@ jobs: Write-Host "Moving $poFile to $targetDir/nvda.po" Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force } - $mdFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.md" - if (Test-Path -PathType Leaf $mdFile) { + $xliffFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.xliff" + if (Test-Path -PathType Leaf $xliffFile) { $targetDir = "addon/doc/$langCode" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null - Write-Host "Moving $mdFile to $targetDir/readme.md" - Move-Item -Path $mdFile -Destination "$targetDir/readme.md" -Force + Write-Host "Moving $xliffFile to $targetDir/readme.md" + uv run l10nUtil.exe xliff2md $xliffFile "$targetDir/readme.md" } } git add addon/locale addon/doc From 5eafa641d1bb7a75563842f11f850122c41a6351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 18 Apr 2026 07:09:08 +0200 Subject: [PATCH 058/100] Update setOutputs to set just add-on id to download translations --- .github/workflows/setOutputs.py | 40 +-------------------------------- 1 file changed, 1 insertion(+), 39 deletions(-) diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py index d8cce3b..a53aeb0 100644 --- a/.github/workflows/setOutputs.py +++ b/.github/workflows/setOutputs.py @@ -4,55 +4,17 @@ import os import sys -import json sys.path.insert(0, os.getcwd()) import buildVars -import sha256 def main(): addonId = buildVars.addon_info["addon_name"] - readmeFile = os.path.join(os.getcwd(), "readme.md") - i18nSources = sorted(buildVars.i18nSources) - readmeSha = None - i18nSourcesSha = None - shouldUpdateMd = False - shouldUpdatePot = False - shouldAddMdFromScratch = False - shouldAddPotFromScratch = False - if os.path.isfile(readmeFile): - readmeSha = sha256.sha256_checksum([readmeFile]) - i18nSourcesSha = sha256.sha256_checksum(i18nSources) - hashFile = os.path.join(os.getcwd(), "hash.json") - data = dict() - if os.path.isfile(hashFile): - with open(hashFile, "rt") as f: - data = json.load(f) - shouldUpdateMd = data.get("readmeSha") != readmeSha and data.get("readmeSha") is not None - shouldUpdatePot = ( - data.get("i18nSourcesSha") != i18nSourcesSha and data.get("i18nSourcesSha") is not None - ) - shouldAddMdFromScratch = data.get("readmeSha") is None - shouldAddPotFromScratch = data.get("i18nSourcesSha") is None - if readmeSha is not None: - data["readmeSha"] = readmeSha - if i18nSourcesSha is not None: - data["i18nSourcesSha"] = i18nSourcesSha - with open(hashFile, "wt", encoding="utf-8") as f: - json.dump(data, f, indent="\t", ensure_ascii=False) name = "addonId" value = addonId - name0 = "shouldUpdateMd" - value0 = str(shouldUpdateMd).lower() - name1 = "shouldUpdatePot" - value1 = str(shouldUpdatePot).lower() - name2 = "shouldAddMdFromScratch" - value2 = str(shouldAddMdFromScratch).lower() - name3 = "shouldAddPotFromScratch" - value3 = str(shouldAddPotFromScratch).lower() with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n{name0}={value0}\n{name1}={value1}\n{name2}={value2}\n{name3}={value3}\n") + f.write(f"{name}={value}\n") if __name__ == "__main__": From 0eb3223e395c6c319d8c06c5162ce9dff840c013 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 20 Apr 2026 06:48:46 +0200 Subject: [PATCH 059/100] [crowdinL10n.yml] improve CI translation workflow - move Python helper scripts from .github/workflows to .github/scripts for better separation of concerns - add polib dependency and switch to uv sync for reproducible CI environment - fix missing GH_TOKEN required for GitHub CLI (gh) commands - fix l10nUtil.exe path resolution (use ./l10nUtil.exe instead of _l10n/l10nUtil.exe) - improve Crowdin download behavior by avoiding processing empty translation files - refine PO handling: preserve local translations, conditionally upload to Crowdin when needed - refine XLIFF handling: update local documentation only, no upload back to Crowdin - ensure safer, more deterministic, and more predictable translation synchronization logic --- .github/scripts/checkTranslation.py | 106 +++++++++++++++++++ .github/scripts/setOutputs.py | 21 ++++ .github/workflows/crowdinL10n.yml | 153 +++++++++++++++++++++++----- pyproject.toml | 1 + 4 files changed, 253 insertions(+), 28 deletions(-) create mode 100644 .github/scripts/checkTranslation.py create mode 100644 .github/scripts/setOutputs.py diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py new file mode 100644 index 0000000..5d9e5a4 --- /dev/null +++ b/.github/scripts/checkTranslation.py @@ -0,0 +1,106 @@ +import sys +import os +import xml.etree.ElementTree as ET +import polib + + +def normalize(s: str) -> str: + # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) + return " ".join((s or "").strip().lower().split()) + + +# ----------------------------- +# PO FILE CHECK +# ----------------------------- +def checkPo(path: str) -> float: + # Parse PO file using polib + po = polib.pofile(path) + + translated = 0 + total = 0 + + for entry in po: + # Skip empty msgid entries + if not entry.msgid.strip(): + continue + + total += 1 + + # Consider entry translated only if msgstr differs from msgid + if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): + translated += 1 + + return translated / total if total else 0.0 + + +# ----------------------------- +# XLIFF CHECK (skeleton-safe generic parsing) +# ----------------------------- +def checkXliff(path: str) -> float: + # Parse XML XLIFF file + tree = ET.parse(path) + root = tree.getroot() + + translated = 0 + total = 0 + + source = None + + for elem in root.iter(): + + # Capture source segments + if elem.tag.endswith("source"): + source = normalize(elem.text) + + # Compare with target segments + elif elem.tag.endswith("target"): + target = normalize(elem.text) + + if source: + total += 1 + + # Count as translated only if target differs from source + if target and target != source: + translated += 1 + + return translated / total if total else 0.0 + + +# ----------------------------- +# MAIN ENTRY POINT +# ----------------------------- +def main(): + if len(sys.argv) < 2: + print("Usage: checkTranslation.py ") + sys.exit(2) + + path = sys.argv[1] + + if not os.path.exists(path): + print(f"File not found: {path}") + sys.exit(2) + + ext = os.path.splitext(path)[1].lower() + + # Dispatch based on file type + if ext == ".po": + ratio = checkPo(path) + + elif ext in [".xliff", ".xlf"]: + ratio = checkXliff(path) + + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) + + print(f"translation_ratio={ratio}") + + # Threshold: consider file translated if above 5% + if ratio > 0.05: + sys.exit(0) # translated + else: + sys.exit(1) # not translated + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py new file mode 100644 index 0000000..a53aeb0 --- /dev/null +++ b/.github/scripts/setOutputs.py @@ -0,0 +1,21 @@ +# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +import os +import sys + +sys.path.insert(0, os.getcwd()) +import buildVars + + +def main(): + addonId = buildVars.addon_info["addon_name"] + name = "addonId" + value = addonId + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a9e23db..be509ad 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -1,71 +1,168 @@ name: Crowdin l10n on: - workflow_dispatch: schedule: # Every Monday at 00:00 UTC - cron: '0 0 * * 1' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true + env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n + GH_TOKEN: ${{ github.token }} + jobs: crowdinSync: runs-on: windows-latest permissions: contents: write + steps: - name: Checkout add-on uses: actions/checkout@v6 with: submodules: true - - name: "Set up Python" + + - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install the latest version of uv + + - name: Install uv uses: astral-sh/setup-uv@v7 + + - name: Install dependencies + run: uv sync + - name: Get add-on info id: getAddonInfo shell: pwsh + run: uv run ./.github/scripts/setOutputs.py + + - name: Download l10nUtil run: | - uv sync - uv run ./.github/workflows/setOutputs.py - - name: Download l10nUtil from nvdal10n - run: | - # Download the latest release asset matching the pattern from the specified repository - gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" + gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" + - name: Download translations from Crowdin + shell: pwsh run: | - uv run _l10n/l10nUtil.exe exportTranslations -o _addonL10n -c addon + ./l10nUtil.exe exportTranslations -o _addonL10n -c addon + New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null - foreach ($dir in Get-ChildItem -Path "_addonL10n/${{ steps.getAddonInfo.outputs.addonId }}" -Directory) { - Write-Host "Processing: $($dir.FullName)" + + $addonId = "${{ steps.getAddonInfo.outputs.addonId }}" + + foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + + Write-Host "==============================" + Write-Host "Processing: $($dir.Name)" + Write-Host "==============================" + + # ============================ + # LANG SETUP (IMPORTANT) + # ============================ $langCode = $dir.Name - $poFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.po" + + # ============================ + # FILE PATHS + # ============================ + $poFile = Join-Path $dir.FullName "$addonId.po" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $targetDocDir = "addon/doc/$langCode" + $readmePath = "$targetDocDir/readme.md" + + # ============================ + # PO PROCESSING + # ============================ if (Test-Path -PathType Leaf $poFile) { - $targetDir = "addon/locale/$langCode/LC_MESSAGES" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null - Write-Host "Moving $poFile to $targetDir/nvda.po" - Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + + Write-Host "Running PO translation check..." + + uv run ./.github/scripts/checkTranslation.py "$poFile" + $isTranslated = ($LASTEXITCODE -eq 0) + + if ($isTranslated) { + + Write-Host "PO translated → updating local repo" + + $targetDir = "addon/locale/$langCode/LC_MESSAGES" + New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + + Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + + } else { + + Write-Host "PO not translated → skipping local update" + + $addonName = "${{ steps.getAddonInfo.outputs.addonId }}" + $crowdinFile = "$addonName.po" + + if (Test-Path -PathType Leaf $localPoPath) { + + Write-Host "Uploading local PO to Crowdin (no translations found remotely)" + + ./l10nUtil.exe uploadTranslationFile $langCode $crowdinFile $localPoPath -c addon + + } else { + + Write-Host "Local PO file does not exist → skipping upload" + } + } } - $xliffFile = Join-Path $dir.FullName "${{ steps.getAddonInfo.outputs.addonId }}.xliff" + + # ============================ + # XLIFF PROCESSING + # ============================ + $isXliffTranslated = $false + if (Test-Path -PathType Leaf $xliffFile) { - $targetDir = "addon/doc/$langCode" - Write-Host "Moving $xliffFile to $targetDir/readme.md" - uv run l10nUtil.exe xliff2md $xliffFile "$targetDir/readme.md" + + Write-Host "Running XLIFF translation check..." + + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + + if ($isXliffTranslated) { + + Write-Host "XLIFF translated → updating README" + + New-Item -ItemType Directory -Force -Path $targetDocDir | Out-Null + + uv run l10nUtil.exe xliff2md $xliffFile $readmePath + + } else { + + Write-Host "XLIFF not translated → skipping README update" + } + + Write-Host "XLIFF translation result: $isXliffTranslated" } } + + # ============================ + # COMMIT CHANGES + # ============================ + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add addon/locale addon/doc - $diff = git diff --staged --quiet - if ($LASTEXITCODE -eq 0) { - Write-Host "Nothing added to commit." - } else { - git commit -m "Update translations for ${{ steps.getAddonInfo.outputs.addonId }}" - git checkout -b ${{ env.downloadTranslationsBranch }} + + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch ${{ env.downloadTranslationsBranch }} 2>$null + + if ($LASTEXITCODE -ne 0) { + git switch -c ${{ env.downloadTranslationsBranch }} + } git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - } + } else { + Write-Host "Nothing to commit." + } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index aa8752d..1f06e05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", + "polib", # Lint "uv==0.9.11", "ruff==0.14.5", From a1951107922df6e59d3be324a5f8676c3e8dc7bd Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Mon, 20 Apr 2026 08:17:14 +0200 Subject: [PATCH 060/100] Remove duplicate setOutputs.py from .github/workflows The script is already available in .github/scripts/. --- .github/workflows/setOutputs.py | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/setOutputs.py diff --git a/.github/workflows/setOutputs.py b/.github/workflows/setOutputs.py deleted file mode 100644 index a53aeb0..0000000 --- a/.github/workflows/setOutputs.py +++ /dev/null @@ -1,21 +0,0 @@ -# Copyright (C) 2025 NV Access Limited, Noelia Ruiz Martínez -# This file is covered by the GNU General Public License. -# See the file COPYING for more details. - -import os -import sys - -sys.path.insert(0, os.getcwd()) -import buildVars - - -def main(): - addonId = buildVars.addon_info["addon_name"] - name = "addonId" - value = addonId - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") - - -if __name__ == "__main__": - main() From e62bd5bfc84e9eb6c028c7820b448d251b053c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 18:40:14 +0200 Subject: [PATCH 061/100] Update lock --- uv.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/uv.lock b/uv.lock index e0def29..0ac096f 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ dependencies = [ { name = "mdx-gh-links" }, { name = "mdx-truly-sane-lists" }, { name = "nh3" }, + { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, { name = "requests" }, @@ -30,6 +31,7 @@ requires-dist = [ { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, + { name = "polib" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, { name = "requests", specifier = "==2.32.5" }, @@ -270,6 +272,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, ] +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + [[package]] name = "pre-commit" version = "4.2.0" From 91995bd24a9448bc95ae2bf6b33c0427aa2968be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 19:13:08 +0200 Subject: [PATCH 062/100] Try to fix pre-commit configuration --- .github/scripts/checkTranslation.py | 117 ++++++++++++++-------------- .github/scripts/setOutputs.py | 2 +- .github/workflows/crowdinL10n.yml | 2 +- .pre-commit-config.yaml | 5 +- sha256.py | 11 ++- 5 files changed, 68 insertions(+), 69 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 5d9e5a4..bf39e81 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -4,103 +4,102 @@ import polib -def normalize(s: str) -> str: - # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) - return " ".join((s or "").strip().lower().split()) +def normalize(s: str | None) -> str: + # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) + return " ".join((s or "").strip().lower().split()) # ----------------------------- # PO FILE CHECK # ----------------------------- def checkPo(path: str) -> float: - # Parse PO file using polib - po = polib.pofile(path) + # Parse PO file using polib + po = polib.pofile(path) - translated = 0 - total = 0 + translated = 0 + total = 0 - for entry in po: - # Skip empty msgid entries - if not entry.msgid.strip(): - continue + for entry in po: + # Skip empty msgid entries + if not entry.msgid.strip(): + continue - total += 1 + total += 1 - # Consider entry translated only if msgstr differs from msgid - if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): - translated += 1 + # Consider entry translated only if msgstr differs from msgid + if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): + translated += 1 - return translated / total if total else 0.0 + return translated / total if total else 0.0 # ----------------------------- # XLIFF CHECK (skeleton-safe generic parsing) # ----------------------------- def checkXliff(path: str) -> float: - # Parse XML XLIFF file - tree = ET.parse(path) - root = tree.getroot() + # Parse XML XLIFF file + tree = ET.parse(path) + root = tree.getroot() - translated = 0 - total = 0 + translated = 0 + total = 0 - source = None + source = None - for elem in root.iter(): + for elem in root.iter(): + # Capture source segments + if elem.tag.endswith("source"): + source = normalize(elem.text) - # Capture source segments - if elem.tag.endswith("source"): - source = normalize(elem.text) + # Compare with target segments + elif elem.tag.endswith("target"): + target = normalize(elem.text) - # Compare with target segments - elif elem.tag.endswith("target"): - target = normalize(elem.text) + if source: + total += 1 - if source: - total += 1 + # Count as translated only if target differs from source + if target and target != source: + translated += 1 - # Count as translated only if target differs from source - if target and target != source: - translated += 1 - - return translated / total if total else 0.0 + return translated / total if total else 0.0 # ----------------------------- # MAIN ENTRY POINT # ----------------------------- def main(): - if len(sys.argv) < 2: - print("Usage: checkTranslation.py ") - sys.exit(2) + if len(sys.argv) < 2: + print("Usage: checkTranslation.py ") + sys.exit(2) - path = sys.argv[1] + path = sys.argv[1] - if not os.path.exists(path): - print(f"File not found: {path}") - sys.exit(2) + if not os.path.exists(path): + print(f"File not found: {path}") + sys.exit(2) - ext = os.path.splitext(path)[1].lower() + ext = os.path.splitext(path)[1].lower() - # Dispatch based on file type - if ext == ".po": - ratio = checkPo(path) + # Dispatch based on file type + if ext == ".po": + ratio = checkPo(path) - elif ext in [".xliff", ".xlf"]: - ratio = checkXliff(path) + elif ext in [".xliff", ".xlf"]: + ratio = checkXliff(path) - else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) - print(f"translation_ratio={ratio}") + print(f"translation_ratio={ratio}") - # Threshold: consider file translated if above 5% - if ratio > 0.05: - sys.exit(0) # translated - else: - sys.exit(1) # not translated + # Threshold: consider file translated if above 5% + if ratio > 0.05: + sys.exit(0) # translated + else: + sys.exit(1) # not translated if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index a53aeb0..a5d9161 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -14,7 +14,7 @@ def main(): name = "addonId" value = addonId with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") + _ = f.write(f"{name}={value}\n") if __name__ == "__main__": diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index be509ad..8378d51 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -165,4 +165,4 @@ jobs: git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} } else { Write-Host "Nothing to commit." - } \ No newline at end of file + } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea70058..e8c5026 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,10 +76,11 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff +- repo: https://github.com/astral-sh/uv-pre-commit + rev: 0.11.4 + hooks: - id: uv-lock name: Verify uv lock file - # Override python interpreter from .python-versions as that is too strict for pre-commit.ci - args: ["-p3.13"] - repo: local hooks: diff --git a/sha256.py b/sha256.py index 51c903b..d2d455b 100644 --- a/sha256.py +++ b/sha256.py @@ -17,17 +17,16 @@ def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = :return: The Sha256 hex digest. """ sha256 = hashlib.sha256() - for f in binaryReadModeFiles: - with open(f, "rb") as file: - assert file.readable() and file.mode == "rb" - for block in iter(lambda: file.read(blockSize), b""): - sha256.update(block) + for file in binaryReadModeFiles: + assert file.readable() and file.mode == "rb" + for block in iter(lambda: file.read(blockSize), b""): + sha256.update(block) return sha256.hexdigest() def main(): parser = argparse.ArgumentParser() - parser.add_argument( + _ = parser.add_argument( type=argparse.FileType("rb"), dest="file", help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", From 0c613be08597470b38cc03fb0fd2ed62f3aaea52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 19:53:00 +0200 Subject: [PATCH 063/100] Reset gitignore to master --- .gitignore | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index 86b975e..a6ccee5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,12 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv - -# Files generated for add-ons addon/doc/*.css addon/doc/en/ *_docHandler.py *.html -addon/*.ini -addon/locale/*/*.ini +manifest.ini *.mo *.pot -*.pyc +*.py[co] *.nvda-addon .sconsign.dblite +/[0-9]*.[0-9]*.[0-9]*.json *.egg-info From 0b6507252d3e84641c437bbb1996766fdf86ec1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:00:46 +0200 Subject: [PATCH 064/100] Require polib 1.2.0 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 43e1e84..b9233f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - "polib", + "polib==1.2.0", # Lint "uv==0.11.6", "ruff==0.14.5", From f9ba8fee37e8d5d0ee06faabe34f8950ff7ad2fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:01:24 +0200 Subject: [PATCH 065/100] Update lock file --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 2b8a928..5176c15 100644 --- a/uv.lock +++ b/uv.lock @@ -31,7 +31,7 @@ requires-dist = [ { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, - { name = "polib" }, + { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, { name = "requests", specifier = "==2.33.0" }, From 8e514ba304e452d97d03afebf5121107e91f68eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:08:27 +0200 Subject: [PATCH 066/100] Remove sha256 file --- sha256.py | 40 ---------------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 sha256.py diff --git a/sha256.py b/sha256.py deleted file mode 100644 index d2d455b..0000000 --- a/sha256.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright (C) 2020-2025 NV Access Limited, Noelia Ruiz Martínez -# This file may be used under the terms of the GNU General Public License, version 2 or later. -# For more details see: https://www.gnu.org/licenses/gpl-2.0.html - -import argparse -import hashlib -import typing - -#: The read size for each chunk read from the file, prevents memory overuse with large files. -BLOCK_SIZE = 65536 - - -def sha256_checksum(binaryReadModeFiles: list[typing.BinaryIO], blockSize: int = BLOCK_SIZE): - """ - :param binaryReadModeFiles: A list of files (mode=='rb'). Calculate its sha256 hash. - :param blockSize: The size of each read. - :return: The Sha256 hex digest. - """ - sha256 = hashlib.sha256() - for file in binaryReadModeFiles: - assert file.readable() and file.mode == "rb" - for block in iter(lambda: file.read(blockSize), b""): - sha256.update(block) - return sha256.hexdigest() - - -def main(): - parser = argparse.ArgumentParser() - _ = parser.add_argument( - type=argparse.FileType("rb"), - dest="file", - help="The NVDA addon (*.nvda-addon) to use when computing the sha256.", - ) - args = parser.parse_args() - checksum = sha256_checksum(args.file) - print(f"Sha256:\t {checksum}") - - -if __name__ == "__main__": - main() From 9c642478ec4ac0dc9ec0497684c612a2535c009f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:13:41 +0200 Subject: [PATCH 067/100] Remove verification of lock file in pre-commit config, not present in master branch --- .pre-commit-config.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e8c5026..f4c3e91 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,11 +76,6 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff -- repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.11.4 - hooks: - - id: uv-lock - name: Verify uv lock file - repo: local hooks: From 7589fef76911f4554a6309482199ba44ea50808e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:21:49 +0200 Subject: [PATCH 068/100] Remove Crowdin client --- pyproject.toml | 2 -- 1 file changed, 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9233f1..3d10490 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,9 +25,7 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management - "requests==2.33.0", "nh3==0.3.2", - "crowdin-api-client==1.24.1", "lxml==6.0.2", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", From 94b1a88c995b6904c2505c1d04a3f6b55c2de25a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 20 Apr 2026 20:22:08 +0200 Subject: [PATCH 069/100] Update lock file --- uv.lock | 129 -------------------------------------------------------- 1 file changed, 129 deletions(-) diff --git a/uv.lock b/uv.lock index 5176c15..0701df0 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,6 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ - { name = "crowdin-api-client" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -16,7 +15,6 @@ dependencies = [ { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, - { name = "requests" }, { name = "ruff" }, { name = "scons" }, { name = "uv" }, @@ -24,7 +22,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "crowdin-api-client", specifier = "==1.24.1" }, { name = "lxml", specifier = "==6.0.2" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -34,21 +31,11 @@ requires-dist = [ { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, - { name = "requests", specifier = "==2.33.0" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, { name = "uv", specifier = "==0.11.6" }, ] -[[package]] -name = "certifi" -version = "2025.11.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" }, -] - [[package]] name = "cfgv" version = "3.5.0" @@ -58,56 +45,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] -[[package]] -name = "charset-normalizer" -version = "3.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, - { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, -] - -[[package]] -name = "crowdin-api-client" -version = "1.24.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "deprecated" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, -] - -[[package]] -name = "deprecated" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "wrapt" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, -] - [[package]] name = "distlib" version = "0.4.0" @@ -135,15 +72,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] -[[package]] -name = "idna" -version = "3.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, -] - [[package]] name = "lxml" version = "6.0.2" @@ -333,21 +261,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] -[[package]] -name = "requests" -version = "2.33.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { 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" } -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" }, -] - [[package]] name = "ruff" version = "0.14.5" @@ -392,15 +305,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "urllib3" -version = "2.6.3" -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" } -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" }, -] - [[package]] name = "uv" version = "0.11.6" @@ -440,36 +344,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544 wheels = [ { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] - -[[package]] -name = "wrapt" -version = "2.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, - { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, - { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, - { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, - { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, - { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, - { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, - { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, - { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, - { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, - { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, - { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, - { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, - { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, - { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, - { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, - { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, - { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, - { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, -] From 65290e4643019111e91b5f658eae3da776c55ffa Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Thu, 23 Apr 2026 00:27:29 +0200 Subject: [PATCH 070/100] Enhance Crowdin l10n workflow with MD quality evaluation and comparison logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Markdown language scoring (langid) in checkTranslation.py - Extend script to support MD files and optional multi-file comparison - Update workflow to handle XLIFF → MD conversion only when translated - Implement multi-source comparison (XLIFF MD, remote MD, local MD) - Apply best-quality selection before updating or uploading files - Add full logging for all decision branches - Improve fallback behavior when only one source is available --- .github/scripts/checkTranslation.py | 132 +++++++++++++----- .github/workflows/crowdinL10n.yml | 206 +++++++++++++++++++++------- pyproject.toml | 1 + 3 files changed, 258 insertions(+), 81 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index bf39e81..92100ec 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,78 +2,134 @@ import os import xml.etree.ElementTree as ET import polib - +import langid def normalize(s: str | None) -> str: - # Normalize strings for reliable comparison (trim, lowercase, collapse spaces) return " ".join((s or "").strip().lower().split()) - # ----------------------------- -# PO FILE CHECK +# PO CHECK # ----------------------------- + def checkPo(path: str) -> float: - # Parse PO file using polib po = polib.pofile(path) - translated = 0 total = 0 for entry in po: - # Skip empty msgid entries if not entry.msgid.strip(): continue total += 1 - # Consider entry translated only if msgstr differs from msgid if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): translated += 1 return translated / total if total else 0.0 - # ----------------------------- -# XLIFF CHECK (skeleton-safe generic parsing) +# XLIFF CHECK # ----------------------------- + def checkXliff(path: str) -> float: - # Parse XML XLIFF file tree = ET.parse(path) root = tree.getroot() - translated = 0 total = 0 - source = None for elem in root.iter(): - # Capture source segments if elem.tag.endswith("source"): source = normalize(elem.text) - # Compare with target segments elif elem.tag.endswith("target"): target = normalize(elem.text) if source: total += 1 - - # Count as translated only if target differs from source if target and target != source: translated += 1 return translated / total if total else 0.0 +# ----------------------------- +# MD LANGUAGE SCORE (langid) +# ----------------------------- + +def scoreMd(path: str, expected_lang: str) -> float: + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + except Exception: + return 0.0 + + if not text.strip(): + return 0.0 + + lang, score = langid.classify(text) + + # Normalize score into positive confidence + confidence = 1 / (1 + abs(score)) + + if lang == expected_lang: + return confidence + else: + return 0.0 + +# ----------------------------- +# COMPARE MULTIPLE MD FILES +# ----------------------------- + +def compareMd(files: list[str], lang: str): + results = [] + + for f in files: + if not os.path.exists(f): + continue + + score = scoreMd(f, lang) + results.append((f, score)) + + if not results: + print("winner=None") + sys.exit(1) + + results.sort(key=lambda x: x[1], reverse=True) + + winner = results[0] + + print("comparison_results:") + for f, s in results: + print(f"{f}={s}") + + print(f"winner={winner[0]}") + print(f"winner_score={winner[1]}") + + sys.exit(0) # ----------------------------- -# MAIN ENTRY POINT +# MAIN # ----------------------------- + def main(): if len(sys.argv) < 2: - print("Usage: checkTranslation.py ") + print("Usage:") + print(" checkTranslation.py ") + print(" checkTranslation.py ") + print(" checkTranslation.py [...] ") sys.exit(2) - path = sys.argv[1] + args = sys.argv[1:] + + # ------------------------- + # MULTI FILE MODE + # ------------------------- + if len(args) >= 3: + *files, lang = args + compareMd(files, lang) + return + + path = args[0] if not os.path.exists(path): print(f"File not found: {path}") @@ -81,25 +137,39 @@ def main(): ext = os.path.splitext(path)[1].lower() - # Dispatch based on file type + # ------------------------- + # PO + # ------------------------- if ext == ".po": ratio = checkPo(path) + print(f"translation_ratio={ratio}") + sys.exit(0 if ratio > 0.05 else 1) + # ------------------------- + # XLIFF + # ------------------------- elif ext in [".xliff", ".xlf"]: ratio = checkXliff(path) + print(f"translation_ratio={ratio}") + sys.exit(0 if ratio > 0.05 else 1) - else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + # ------------------------- + # MD (LANG SCORE) + # ------------------------- + elif ext == ".md": + if len(args) < 2: + print("Missing language argument for MD scoring") + sys.exit(2) - print(f"translation_ratio={ratio}") + lang = args[1] + score = scoreMd(path, lang) - # Threshold: consider file translated if above 5% - if ratio > 0.05: - sys.exit(0) # translated - else: - sys.exit(1) # not translated + print(f"md_score={score}") + sys.exit(0) + else: + print(f"Unsupported file type: {ext}") + sys.exit(2) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 8378d51..f0022c1 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,7 +3,6 @@ name: Crowdin l10n on: workflow_dispatch: schedule: - # Every Monday at 00:00 UTC - cron: '0 0 * * 1' concurrency: @@ -20,7 +19,6 @@ jobs: runs-on: windows-latest permissions: contents: write - steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -60,95 +58,202 @@ jobs: foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" - Write-Host "Processing: $($dir.Name)" + Write-Host "Processing language: $($dir.Name)" Write-Host "==============================" - # ============================ - # LANG SETUP (IMPORTANT) - # ============================ $langCode = $dir.Name + $langShort = $langCode.Split('_')[0] - # ============================ - # FILE PATHS - # ============================ + # Paths $poFile = Join-Path $dir.FullName "$addonId.po" $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $remoteMd = Join-Path $dir.FullName "$addonId.md" + $targetDocDir = "addon/doc/$langCode" - $readmePath = "$targetDocDir/readme.md" + $localMd = "$targetDocDir/readme.md" + + # ---------------------------- + # SKIP ENGLISH (source language) + # ---------------------------- + if ($langCode -eq "en") { + Write-Host "Skipping English (source language) → no MD/XLIFF processing required" + continue + } - # ============================ + # ---------------------------- # PO PROCESSING - # ============================ - if (Test-Path -PathType Leaf $poFile) { - - Write-Host "Running PO translation check..." + # ---------------------------- + if (Test-Path $poFile) { + Write-Host "Checking PO file..." uv run ./.github/scripts/checkTranslation.py "$poFile" - $isTranslated = ($LASTEXITCODE -eq 0) + $isPoTranslated = ($LASTEXITCODE -eq 0) - if ($isTranslated) { + Write-Host "PO translated: $isPoTranslated" - Write-Host "PO translated → updating local repo" + if ($isPoTranslated) { + Write-Host "Updating local PO" + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + Move-Item $poFile $localPoPath -Force + } else { + Write-Host "PO not translated" + if (Test-Path $localPoPath) { + Write-Host "Uploading local PO to Crowdin" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon + } else { + Write-Host "No local PO available" + } + } + } - $targetDir = "addon/locale/$langCode/LC_MESSAGES" - New-Item -ItemType Directory -Force -Path $targetDir | Out-Null + # ---------------------------- + # XLIFF PROCESSING + # ---------------------------- + $xliffValid = $false + $tempMd = $null - Move-Item -Path $poFile -Destination "$targetDir/nvda.po" -Force + if (Test-Path $xliffFile) { + Write-Host "Checking XLIFF..." - } else { + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $xliffValid = ($LASTEXITCODE -eq 0) + + Write-Host "XLIFF valid: $xliffValid" + + if ($xliffValid) { + Write-Host "Converting XLIFF → MD" + $tempMd = "$env:TEMP\readme_$langCode.md" + ./l10nUtil.exe xliff2md $xliffFile $tempMd + } + } + + $remoteExists = Test-Path $remoteMd + $localExists = Test-Path $localMd + + Write-Host "Remote MD exists: $remoteExists" + Write-Host "Local MD exists: $localExists" + + # ---------------------------- + # DECISION ENGINE + # ---------------------------- + + # CASE: XLIFF VALID + if ($xliffValid) { + Write-Host "Entering XLIFF-driven logic" + + if ($remoteExists -and $localExists) { + Write-Host "3-way comparison (xliff, remote, local)" + + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + + $scoreX = [double]$scoreX + $scoreR = [double]$scoreR + $scoreL = [double]$scoreL - Write-Host "PO not translated → skipping local update" + Write-Host "Scores → XLIFF:$scoreX Remote:$scoreR Local:$scoreL" - $addonName = "${{ steps.getAddonInfo.outputs.addonId }}" - $crowdinFile = "$addonName.po" + $best = [Math]::Max($scoreX, [Math]::Max($scoreR, $scoreL)) - if (Test-Path -PathType Leaf $localPoPath) { + if ($best -eq $scoreX) { + Write-Host "Winner: XLIFF" + Move-Item $tempMd $localMd -Force + } elseif ($best -eq $scoreR) { + Write-Host "Winner: Remote MD" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Winner: Local MD → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } + + } elseif ($remoteExists -and -not $localExists) { + Write-Host "Comparing XLIFF vs Remote" - Write-Host "Uploading local PO to Crowdin (no translations found remotely)" + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - ./l10nUtil.exe uploadTranslationFile $langCode $crowdinFile $localPoPath -c addon + $scoreX = [double]$scoreX + $scoreR = [double]$scoreR + if ($scoreX -ge $scoreR) { + Write-Host "Winner: XLIFF → creating local" + Move-Item $tempMd $localMd -Force } else { + Write-Host "Winner: Remote → creating local" + Move-Item $remoteMd $localMd -Force + } + + } elseif (-not $remoteExists -and $localExists) { + Write-Host "Comparing XLIFF vs Local" + + $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - Write-Host "Local PO file does not exist → skipping upload" + $scoreX = [double]$scoreX + $scoreL = [double]$scoreL + + if ($scoreX -gt $scoreL) { + Write-Host "Winner: XLIFF → overwrite local" + Move-Item $tempMd $localMd -Force + } else { + Write-Host "Winner: Local → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon } + + } else { + Write-Host "Only XLIFF available → importing directly" + Move-Item $tempMd $localMd -Force } - } - # ============================ - # XLIFF PROCESSING - # ============================ - $isXliffTranslated = $false + } else { + Write-Host "XLIFF not usable → fallback logic" - if (Test-Path -PathType Leaf $xliffFile) { + if ($remoteExists -and $localExists) { + Write-Host "Comparing Remote vs Local" - Write-Host "Running XLIFF translation check..." + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) + $scoreR = [double]$scoreR + $scoreL = [double]$scoreL - if ($isXliffTranslated) { + if ($scoreR -gt $scoreL) { + Write-Host "Winner: Remote → overwrite local" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Winner: Local → uploading" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } - Write-Host "XLIFF translated → updating README" + } elseif ($remoteExists -and -not $localExists) { + Write-Host "Remote only → checking quality" - New-Item -ItemType Directory -Force -Path $targetDocDir | Out-Null + $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] + $scoreR = [double]$scoreR - uv run l10nUtil.exe xliff2md $xliffFile $readmePath + if ($scoreR -gt 0.5) { + Write-Host "Remote is valid → importing" + Move-Item $remoteMd $localMd -Force + } else { + Write-Host "Remote not valid → skipping" + } - } else { + } elseif (-not $remoteExists -and $localExists) { + Write-Host "Only local exists → uploading without scoring" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - Write-Host "XLIFF not translated → skipping README update" + } else { + Write-Host "No MD available → nothing to do" } - - Write-Host "XLIFF translation result: $isXliffTranslated" } } - # ============================ - # COMMIT CHANGES - # ============================ + # ---------------------------- + # COMMIT + # ---------------------------- git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -157,11 +262,12 @@ jobs: git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" - git switch ${{ env.downloadTranslationsBranch }} 2>$null + git switch ${{ env.downloadTranslationsBranch }} 2>$null if ($LASTEXITCODE -ne 0) { git switch -c ${{ env.downloadTranslationsBranch }} } + git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} } else { Write-Host "Nothing to commit." diff --git a/pyproject.toml b/pyproject.toml index 3d10490..e1b8a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", "polib==1.2.0", + "langid==1.1.6", # Lint "uv==0.11.6", "ruff==0.14.5", From c9c3bab10735c087f9494896d9316e8bcd959683 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Thu, 23 Apr 2026 01:37:40 +0200 Subject: [PATCH 071/100] Run pre-commit --- .github/scripts/checkTranslation.py | 14 +++++++++- uv.lock | 40 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 92100ec..be6ca0b 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -4,13 +4,16 @@ import polib import langid + def normalize(s: str | None) -> str: return " ".join((s or "").strip().lower().split()) + # ----------------------------- # PO CHECK # ----------------------------- + def checkPo(path: str) -> float: po = polib.pofile(path) translated = 0 @@ -27,10 +30,12 @@ def checkPo(path: str) -> float: return translated / total if total else 0.0 + # ----------------------------- # XLIFF CHECK # ----------------------------- + def checkXliff(path: str) -> float: tree = ET.parse(path) root = tree.getroot() @@ -52,10 +57,12 @@ def checkXliff(path: str) -> float: return translated / total if total else 0.0 + # ----------------------------- # MD LANGUAGE SCORE (langid) # ----------------------------- + def scoreMd(path: str, expected_lang: str) -> float: try: with open(path, "r", encoding="utf-8") as f: @@ -76,10 +83,12 @@ def scoreMd(path: str, expected_lang: str) -> float: else: return 0.0 + # ----------------------------- # COMPARE MULTIPLE MD FILES # ----------------------------- + def compareMd(files: list[str], lang: str): results = [] @@ -107,10 +116,12 @@ def compareMd(files: list[str], lang: str): sys.exit(0) + # ----------------------------- # MAIN # ----------------------------- + def main(): if len(sys.argv) < 2: print("Usage:") @@ -171,5 +182,6 @@ def main(): print(f"Unsupported file type: {ext}") sys.exit(2) + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/uv.lock b/uv.lock index 0701df0..a5ffac5 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ + { name = "langid" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -22,6 +23,7 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "langid", specifier = "==1.1.6" }, { name = "lxml", specifier = "==6.0.2" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -72,6 +74,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, ] +[[package]] +name = "langid" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } + [[package]] name = "lxml" version = "6.0.2" @@ -191,6 +202,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/df/af/cd3290a647df567645353feed451ef4feaf5844496ced69c4dcb84295ff4/nodejs_wheel_binaries-24.12.0-py2.py3-none-win_arm64.whl", hash = "sha256:d0c2273b667dd7e3f55e369c0085957b702144b1b04bfceb7ce2411e58333757", size = 39048104, upload-time = "2025-12-11T21:12:23.495Z" }, ] +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, +] + [[package]] name = "platformdirs" version = "4.5.1" From b1d2acfb1ab26cb552a16a1bfbaddbc7d11d724f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 05:30:55 +0200 Subject: [PATCH 072/100] Restore deleted space --- .pre-commit-config.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f4c3e91..207177d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -76,6 +76,7 @@ repos: args: [ --fix ] - id: ruff-format name: format with ruff + - repo: local hooks: From 263b7e22354939e72eb11bfedb67087adf73139a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 05:32:43 +0200 Subject: [PATCH 073/100] Update lock file --- uv.lock | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 400 insertions(+) create mode 100644 uv.lock diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..bb4070f --- /dev/null +++ b/uv.lock @@ -0,0 +1,400 @@ +version = 1 +revision = 3 +requires-python = "==3.13.*" + +[[package]] +name = "addontemplate" +source = { editable = "." } +dependencies = [ + { name = "langid" }, + { name = "lxml" }, + { name = "markdown" }, + { name = "markdown-link-attr-modifier" }, + { name = "mdx-gh-links" }, + { name = "mdx-truly-sane-lists" }, + { name = "nh3" }, + { name = "polib" }, + { name = "pre-commit" }, + { name = "pyright", extra = ["nodejs"] }, + { name = "ruff" }, + { name = "scons" }, + { name = "uv" }, +] + +[package.metadata] +requires-dist = [ + { name = "langid", specifier = "==1.1.6" }, + { name = "lxml", specifier = "==6.1.0" }, + { name = "markdown", specifier = "==3.10" }, + { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, + { name = "mdx-gh-links", specifier = "==0.4" }, + { name = "mdx-truly-sane-lists", specifier = "==1.3" }, + { name = "nh3", specifier = "==0.3.2" }, + { name = "polib", specifier = "==1.2.0" }, + { name = "pre-commit", specifier = "==4.2.0" }, + { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "ruff", specifier = "==0.14.5" }, + { name = "scons", specifier = "==4.10.1" }, + { name = "uv", specifier = "==0.11.6" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +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" } +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" }, +] + +[[package]] +name = "filelock" +version = "3.29.0" +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" } +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" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "langid" +version = "1.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } + +[[package]] +name = "lxml" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/28/30/9abc9e34c657c33834eaf6cd02124c61bdf5944d802aa48e69be8da3585d/lxml-6.1.0.tar.gz", hash = "sha256:bfd57d8008c4965709a919c3e9a98f76c2c7cb319086b3d26858250620023b13", size = 4197006, upload-time = "2026-04-18T04:32:51.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/03/69347590f1cf4a6d5a4944bb6099e6d37f334784f16062234e1f892fdb1d/lxml-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a0092f2b107b69601adf562a57c956fbb596e05e3e6651cabd3054113b007e45", size = 8559689, upload-time = "2026-04-18T04:31:57.785Z" }, + { url = "https://files.pythonhosted.org/packages/3f/58/25e00bb40b185c974cfe156c110474d9a8a8390d5f7c92a4e328189bb60e/lxml-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fc7140d7a7386e6b545d41b7358f4d02b656d4053f5fa6859f92f4b9c2572c4d", size = 4617892, upload-time = "2026-04-18T04:32:01.78Z" }, + { url = "https://files.pythonhosted.org/packages/f5/54/92ad98a94ac318dc4f97aaac22ff8d1b94212b2ae8af5b6e9b354bf825f7/lxml-6.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:419c58fc92cc3a2c3fa5f78c63dbf5da70c1fa9c1b25f25727ecee89a96c7de2", size = 4923489, upload-time = "2026-04-18T04:33:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/15/3b/a20aecfab42bdf4f9b390590d345857ad3ffd7c51988d1c89c53a0c73faf/lxml-6.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:37fabd1452852636cf38ecdcc9dd5ca4bba7a35d6c53fa09725deeb894a87491", size = 5082162, upload-time = "2026-04-18T04:33:34.262Z" }, + { url = "https://files.pythonhosted.org/packages/45/26/2cdb3d281ac1bd175603e290cbe4bad6eff127c0f8de90bafd6f8548f0fd/lxml-6.1.0-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2853c8b2170cc6cd54a6b4d50d2c1a8a7aeca201f23804b4898525c7a152cfc", size = 4993247, upload-time = "2026-04-18T04:33:36.674Z" }, + { url = "https://files.pythonhosted.org/packages/f6/05/d735aef963740022a08185c84821f689fc903acb3d50326e6b1e9886cc22/lxml-6.1.0-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e369cbd690e788c8d15e56222d91a09c6a417f49cbc543040cba0fe2e25a79e", size = 5613042, upload-time = "2026-04-18T04:33:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/b8/ead7c10efff731738c72e59ed6eb5791854879fbed7ae98781a12006263a/lxml-6.1.0-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e69aa6805905807186eb00e66c6d97a935c928275182eb02ee40ba00da9623b2", size = 5228304, upload-time = "2026-04-18T04:33:41.647Z" }, + { url = "https://files.pythonhosted.org/packages/6b/10/e9842d2ec322ea65f0a7270aa0315a53abed06058b88ef1b027f620e7a5f/lxml-6.1.0-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:4bd1bdb8a9e0e2dd229de19b5f8aebac80e916921b4b2c6ef8a52bc131d0c1f9", size = 5341578, upload-time = "2026-04-18T04:33:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/89/54/40d9403d7c2775fa7301d3ddd3464689bfe9ba71acc17dfff777071b4fdc/lxml-6.1.0-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:cbd7b79cdcb4986ad78a2662625882747f09db5e4cd7b2ae178a88c9c51b3dfe", size = 4700209, upload-time = "2026-04-18T04:33:47.552Z" }, + { url = "https://files.pythonhosted.org/packages/85/b2/bbdcc2cf45dfc7dfffef4fd97e5c47b15919b6a365247d95d6f684ef5e82/lxml-6.1.0-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:43e4d297f11080ec9d64a4b1ad7ac02b4484c9f0e2179d9c4ef78e886e747b88", size = 5232365, upload-time = "2026-04-18T04:33:50.249Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/b06875665e53aaba7127611a7bed3b7b9658e20b22bc2dd217a0b7ab0091/lxml-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cc16682cc987a3da00aa56a3aa3075b08edb10d9b1e476938cfdbee8f3b67181", size = 5043654, upload-time = "2026-04-18T04:33:52.71Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9c/e71a069d09641c1a7abeb30e693f828c7c90a41cbe3d650b2d734d876f85/lxml-6.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:d6d8efe71429635f0559579092bb5e60560d7b9115ee38c4adbea35632e7fa24", size = 4769326, upload-time = "2026-04-18T04:33:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/cc/06/7a9cd84b3d4ed79adf35f874750abb697dec0b4a81a836037b36e47c091a/lxml-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7e39ab3a28af7784e206d8606ec0e4bcad0190f63a492bca95e94e5a4aef7f6e", size = 5635879, upload-time = "2026-04-18T04:33:58.509Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f0/9d57916befc1e54c451712c7ee48e9e74e80ae4d03bdce49914e0aee42cd/lxml-6.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9eb667bf50856c4a58145f8ca2d5e5be160191e79eb9e30855a476191b3c3495", size = 5224048, upload-time = "2026-04-18T04:34:00.943Z" }, + { url = "https://files.pythonhosted.org/packages/99/75/90c4eefda0c08c92221fe0753db2d6699a4c628f76ff4465ec20dea84cc1/lxml-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7f4a77d6f7edf9230cee3e1f7f6764722a41604ee5681844f18db9a81ea0ec33", size = 5250241, upload-time = "2026-04-18T04:34:03.365Z" }, + { url = "https://files.pythonhosted.org/packages/5e/73/16596f7e4e38fa33084b9ccbccc22a15f82a290a055126f2c1541236d2ff/lxml-6.1.0-cp313-cp313-win32.whl", hash = "sha256:28902146ffbe5222df411c5d19e5352490122e14447e98cd118907ee3fd6ee62", size = 3596938, upload-time = "2026-04-18T04:31:56.206Z" }, + { url = "https://files.pythonhosted.org/packages/8e/63/981401c5680c1eb30893f00a19641ac80db5d1e7086c62cb4b13ed813038/lxml-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:4a1503c56e4e2b38dc76f2f2da7bae69670c0f1933e27cfa34b2fa5876410b16", size = 3995728, upload-time = "2026-04-18T04:31:58.763Z" }, + { url = "https://files.pythonhosted.org/packages/e7/e8/c358a38ac3e541d16a1b527e4e9cb78c0419b0506a070ace11777e5e8404/lxml-6.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:e0af85773850417d994d019741239b901b22c6680206f46a34766926e466141d", size = 3658372, upload-time = "2026-04-18T04:32:03.629Z" }, +] + +[[package]] +name = "markdown" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/ab/7dd27d9d863b3376fcf23a5a13cb5d024aed1db46f963f1b5735ae43b3be/markdown-3.10.tar.gz", hash = "sha256:37062d4f2aa4b2b6b32aefb80faa300f82cc790cb949a35b8caede34f2b68c0e", size = 364931, upload-time = "2025-11-03T19:51:15.007Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/81/54e3ce63502cd085a0c556652a4e1b919c45a446bd1e5300e10c44c8c521/markdown-3.10-py3-none-any.whl", hash = "sha256:b5b99d6951e2e4948d939255596523444c0e677c669700b1d17aa4a8a464cb7c", size = 107678, upload-time = "2025-11-03T19:51:13.887Z" }, +] + +[[package]] +name = "markdown-link-attr-modifier" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/30/d35aad054a27f119bff2408523d82c3f9a6d9936712c872f5b9fe817de5b/markdown_link_attr_modifier-0.2.1.tar.gz", hash = "sha256:18df49a9fe7b5c87dad50b75c2a2299ae40c65674f7b1263fb12455f5df7ac99", size = 18408, upload-time = "2023-04-13T16:00:12.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/82/9262a67313847fdcc6252a007f032924fe0c0b6d6b9ef0d0b1fa58952c72/markdown_link_attr_modifier-0.2.1-py3-none-any.whl", hash = "sha256:6b4415319648cbe6dfb7a54ca12fa69e61a27c86a09d15f2a9a559ace0aa87c5", size = 17146, upload-time = "2023-04-13T16:00:06.559Z" }, +] + +[[package]] +name = "mdx-gh-links" +version = "0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2f/ea/bf1f721a8dc0ff83b426480f040ac68dbe3d7898b096c1277a5a4e3da0ec/mdx_gh_links-0.4.tar.gz", hash = "sha256:41d5aac2ab201425aa0a19373c4095b79e5e015fdacfe83c398199fe55ca3686", size = 5783, upload-time = "2023-12-22T19:54:02.136Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c7/ccfe05ade98ba7a63f05d1b05b7508d9af743cbd1f1681aa0c9900a8cd40/mdx_gh_links-0.4-py3-none-any.whl", hash = "sha256:9057bca1fa5280bf1fcbf354381e46c9261cc32c2d5c0407801f8a910be5f099", size = 7166, upload-time = "2023-12-22T19:54:00.384Z" }, +] + +[[package]] +name = "mdx-truly-sane-lists" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/27/16456314311abac2cedef4527679924e80ac4de19dd926699c1b261e0b9b/mdx_truly_sane_lists-1.3.tar.gz", hash = "sha256:b661022df7520a1e113af7c355c62216b384c867e4f59fb8ee7ad511e6e77f45", size = 5359, upload-time = "2022-07-19T13:42:45.153Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/9e/dcd1027f7fd193aed152e01c6651a197c36b858f2cd1425ad04cb31a34fc/mdx_truly_sane_lists-1.3-py3-none-any.whl", hash = "sha256:b9546a4c40ff8f1ab692f77cee4b6bfe8ddf9cccf23f0a24e71f3716fe290a37", size = 6071, upload-time = "2022-07-19T13:42:43.375Z" }, +] + +[[package]] +name = "nh3" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/a5/34c26015d3a434409f4d2a1cd8821a06c05238703f49283ffeb937bef093/nh3-0.3.2.tar.gz", hash = "sha256:f394759a06df8b685a4ebfb1874fb67a9cbfd58c64fc5ed587a663c0e63ec376", size = 19288, upload-time = "2025-10-30T11:17:45.948Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/3e/f5a5cc2885c24be13e9b937441bd16a012ac34a657fe05e58927e8af8b7a/nh3-0.3.2-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e", size = 1431980, upload-time = "2025-10-30T11:17:25.457Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f7/529a99324d7ef055de88b690858f4189379708abae92ace799365a797b7f/nh3-0.3.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8", size = 820805, upload-time = "2025-10-30T11:17:26.98Z" }, + { url = "https://files.pythonhosted.org/packages/3d/62/19b7c50ccd1fa7d0764822d2cea8f2a320f2fd77474c7a1805cb22cf69b0/nh3-0.3.2-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866", size = 803527, upload-time = "2025-10-30T11:17:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ca/f022273bab5440abff6302731a49410c5ef66b1a9502ba3fbb2df998d9ff/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131", size = 1051674, upload-time = "2025-10-30T11:17:29.909Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f7/5728e3b32a11daf5bd21cf71d91c463f74305938bc3eb9e0ac1ce141646e/nh3-0.3.2-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5", size = 1004737, upload-time = "2025-10-30T11:17:31.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/f17e0dba0a99cee29e6cee6d4d52340ef9cb1f8a06946d3a01eb7ec2fb01/nh3-0.3.2-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07", size = 911745, upload-time = "2025-10-30T11:17:32.945Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/c76bf3dba22c73c38e9b1113b017cf163f7696f50e003404ec5ecdb1e8a6/nh3-0.3.2-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7", size = 797184, upload-time = "2025-10-30T11:17:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/08/a1/73d8250f888fb0ddf1b119b139c382f8903d8bb0c5bd1f64afc7e38dad1d/nh3-0.3.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87", size = 838556, upload-time = "2025-10-30T11:17:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d1/09/deb57f1fb656a7a5192497f4a287b0ade5a2ff6b5d5de4736d13ef6d2c1f/nh3-0.3.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a", size = 1006695, upload-time = "2025-10-30T11:17:37.071Z" }, + { url = "https://files.pythonhosted.org/packages/b6/61/8f4d41c4ccdac30e4b1a4fa7be4b0f9914d8314a5058472f84c8e101a418/nh3-0.3.2-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131", size = 1075471, upload-time = "2025-10-30T11:17:38.225Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c6/966aec0cb4705e69f6c3580422c239205d5d4d0e50fac380b21e87b6cf1b/nh3-0.3.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0", size = 1002439, upload-time = "2025-10-30T11:17:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c8/97a2d5f7a314cce2c5c49f30c6f161b7f3617960ade4bfc2fd1ee092cb20/nh3-0.3.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6", size = 987439, upload-time = "2025-10-30T11:17:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/0d/95/2d6fc6461687d7a171f087995247dec33e8749a562bfadd85fb5dbf37a11/nh3-0.3.2-cp38-abi3-win32.whl", hash = "sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b", size = 589826, upload-time = "2025-10-30T11:17:42.239Z" }, + { url = "https://files.pythonhosted.org/packages/64/9a/1a1c154f10a575d20dd634e5697805e589bbdb7673a0ad00e8da90044ba7/nh3-0.3.2-cp38-abi3-win_amd64.whl", hash = "sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe", size = 596406, upload-time = "2025-10-30T11:17:43.773Z" }, + { url = "https://files.pythonhosted.org/packages/9e/7e/a96255f63b7aef032cbee8fc4d6e37def72e3aaedc1f72759235e8f13cb1/nh3-0.3.2-cp38-abi3-win_arm64.whl", hash = "sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104", size = 584162, upload-time = "2025-10-30T11:17:44.96Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "nodejs-wheel-binaries" +version = "24.15.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" } +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" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +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" } +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" }, +] + +[[package]] +name = "polib" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424, upload-time = "2025-03-18T21:35:20.987Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, +] + +[[package]] +name = "pyright" +version = "1.1.407" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/1b/0aa08ee42948b61745ac5b5b5ccaec4669e8884b53d31c8ec20b2fcd6b6f/pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262", size = 4122872, upload-time = "2025-10-24T23:17:15.145Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/93/b69052907d032b00c40cb656d21438ec00b3a471733de137a3f65a49a0a0/pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21", size = 5997008, upload-time = "2025-10-24T23:17:13.159Z" }, +] + +[package.optional-dependencies] +nodejs = [ + { name = "nodejs-wheel-binaries" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.2" +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" } +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" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" }, + { url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" }, + { url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" }, + { url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" }, + { url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" }, + { url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" }, + { url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" }, + { url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" }, + { url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" }, + { url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" }, + { url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" }, +] + +[[package]] +name = "scons" +version = "4.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/c9/2f430bb39e4eccba32ce8008df4a3206df651276422204e177a09e12b30b/scons-4.10.1.tar.gz", hash = "sha256:99c0e94a42a2c1182fa6859b0be697953db07ba936ecc9817ae0d218ced20b15", size = 3258403, upload-time = "2025-11-16T22:43:39.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/bf/931fb9fbb87234c32b8b1b1c15fba23472a10777c12043336675633809a7/scons-4.10.1-py3-none-any.whl", hash = "sha256:bd9d1c52f908d874eba92a8c0c0a8dcf2ed9f3b88ab956d0fce1da479c4e7126", size = 4136069, upload-time = "2025-11-16T22:43:35.933Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uv" +version = "0.11.6" +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" } +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" }, +] + +[[package]] +name = "virtualenv" +version = "21.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { 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" } +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" }, +] From f6e18c281f120ab3832a101cf1a7aba46ad19f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:12:46 +0200 Subject: [PATCH 074/100] Remove score staff --- .github/scripts/checkTranslation.py | 65 ------------- .github/workflows/crowdinL10n.yml | 145 +++------------------------- pyproject.toml | 1 - 3 files changed, 11 insertions(+), 200 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index be6ca0b..eff95d3 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,7 +2,6 @@ import os import xml.etree.ElementTree as ET import polib -import langid def normalize(s: str | None) -> str: @@ -58,70 +57,6 @@ def checkXliff(path: str) -> float: return translated / total if total else 0.0 -# ----------------------------- -# MD LANGUAGE SCORE (langid) -# ----------------------------- - - -def scoreMd(path: str, expected_lang: str) -> float: - try: - with open(path, "r", encoding="utf-8") as f: - text = f.read() - except Exception: - return 0.0 - - if not text.strip(): - return 0.0 - - lang, score = langid.classify(text) - - # Normalize score into positive confidence - confidence = 1 / (1 + abs(score)) - - if lang == expected_lang: - return confidence - else: - return 0.0 - - -# ----------------------------- -# COMPARE MULTIPLE MD FILES -# ----------------------------- - - -def compareMd(files: list[str], lang: str): - results = [] - - for f in files: - if not os.path.exists(f): - continue - - score = scoreMd(f, lang) - results.append((f, score)) - - if not results: - print("winner=None") - sys.exit(1) - - results.sort(key=lambda x: x[1], reverse=True) - - winner = results[0] - - print("comparison_results:") - for f, s in results: - print(f"{f}={s}") - - print(f"winner={winner[0]}") - print(f"winner_score={winner[1]}") - - sys.exit(0) - - -# ----------------------------- -# MAIN -# ----------------------------- - - def main(): if len(sys.argv) < 2: print("Usage:") diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index f0022c1..2d24165 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -69,16 +69,14 @@ jobs: $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $remoteMd = Join-Path $dir.FullName "$addonId.md" - $targetDocDir = "addon/doc/$langCode" - $localMd = "$targetDocDir/readme.md" + $mdFile = Join-Path $targetDocDir "readme.md" # ---------------------------- # SKIP ENGLISH (source language) # ---------------------------- if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no MD/XLIFF processing required" + Write-Host "Skipping English (source language) → no XLIFF processing required" continue } @@ -111,147 +109,26 @@ jobs: # ---------------------------- # XLIFF PROCESSING # ---------------------------- - $xliffValid = $false - $tempMd = $null if (Test-Path $xliffFile) { Write-Host "Checking XLIFF..." uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $xliffValid = ($LASTEXITCODE -eq 0) - - Write-Host "XLIFF valid: $xliffValid" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + Write-Host "XLIFF translated: $isXliffTranslated" - if ($xliffValid) { + if ($isXliffTranslated) { Write-Host "Converting XLIFF → MD" - $tempMd = "$env:TEMP\readme_$langCode.md" - ./l10nUtil.exe xliff2md $xliffFile $tempMd + ./l10nUtil.exe xliff2md $xliffFile $mdFile } + else { + Write-Host "XLIFF not valid" } - - $remoteExists = Test-Path $remoteMd - $localExists = Test-Path $localMd - - Write-Host "Remote MD exists: $remoteExists" - Write-Host "Local MD exists: $localExists" - - # ---------------------------- - # DECISION ENGINE - # ---------------------------- - - # CASE: XLIFF VALID - if ($xliffValid) { - Write-Host "Entering XLIFF-driven logic" - - if ($remoteExists -and $localExists) { - Write-Host "3-way comparison (xliff, remote, local)" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreR = [double]$scoreR - $scoreL = [double]$scoreL - - Write-Host "Scores → XLIFF:$scoreX Remote:$scoreR Local:$scoreL" - - $best = [Math]::Max($scoreX, [Math]::Max($scoreR, $scoreL)) - - if ($best -eq $scoreX) { - Write-Host "Winner: XLIFF" - Move-Item $tempMd $localMd -Force - } elseif ($best -eq $scoreR) { - Write-Host "Winner: Remote MD" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Winner: Local MD → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } elseif ($remoteExists -and -not $localExists) { - Write-Host "Comparing XLIFF vs Remote" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreR = [double]$scoreR - - if ($scoreX -ge $scoreR) { - Write-Host "Winner: XLIFF → creating local" - Move-Item $tempMd $localMd -Force - } else { - Write-Host "Winner: Remote → creating local" - Move-Item $remoteMd $localMd -Force - } - - } elseif (-not $remoteExists -and $localExists) { - Write-Host "Comparing XLIFF vs Local" - - $scoreX = (uv run python .github/scripts/checkTranslation.py "$tempMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreX = [double]$scoreX - $scoreL = [double]$scoreL - - if ($scoreX -gt $scoreL) { - Write-Host "Winner: XLIFF → overwrite local" - Move-Item $tempMd $localMd -Force - } else { - Write-Host "Winner: Local → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } else { - Write-Host "Only XLIFF available → importing directly" - Move-Item $tempMd $localMd -Force - } - - } else { - Write-Host "XLIFF not usable → fallback logic" - - if ($remoteExists -and $localExists) { - Write-Host "Comparing Remote vs Local" - - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreL = (uv run python .github/scripts/checkTranslation.py "$localMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - - $scoreR = [double]$scoreR - $scoreL = [double]$scoreL - - if ($scoreR -gt $scoreL) { - Write-Host "Winner: Remote → overwrite local" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Winner: Local → uploading" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - } - - } elseif ($remoteExists -and -not $localExists) { - Write-Host "Remote only → checking quality" - - $scoreR = (uv run python .github/scripts/checkTranslation.py "$remoteMd" $langShort | Select-String "md_score=").ToString().Split("=")[1] - $scoreR = [double]$scoreR - - if ($scoreR -gt 0.5) { - Write-Host "Remote is valid → importing" - Move-Item $remoteMd $localMd -Force - } else { - Write-Host "Remote not valid → skipping" - } - - } elseif (-not $remoteExists -and $localExists) { - Write-Host "Only local exists → uploading without scoring" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon - - } else { - Write-Host "No MD available → nothing to do" - } + else { + Write-Host "No XLIFF file found" } - } - # ---------------------------- + # COMMIT # ---------------------------- git config user.name "github-actions[bot]" diff --git a/pyproject.toml b/pyproject.toml index becca69..544accf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,6 @@ dependencies = [ "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", "polib==1.2.0", - "langid==1.1.6", # Lint "uv==0.11.6", "ruff==0.14.5", From 55730616e76debfb53f76aa23e00fc0fd491b718 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:41:59 +0200 Subject: [PATCH 075/100] Add ps1 script and rename setOutputs.py --- .github/scripts/crowdinSync.ps1 | 73 +++++++++++++++++++ .../{setOutputs.py => getAddonInfo.py} | 9 +-- 2 files changed, 74 insertions(+), 8 deletions(-) create mode 100644 .github/scripts/crowdinSync.ps1 rename .github/scripts/{setOutputs.py => getAddonInfo.py} (64%) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 new file mode 100644 index 0000000..5bcff67 --- /dev/null +++ b/.github/scripts/crowdinSync.ps1 @@ -0,0 +1,73 @@ +write-host "Exporting translations from Crowdin..." +./l10nUtil.exe exportTranslations -o _addonL10n -c addon + +New-Item -ItemType Directory -Force -Path addon/locale | Out-Null +New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +Write-Host "Getting addon ID..." +$addonId = python ./.github/scripts/getAddonInfo.py 2>$null +$addonId = $addonId.Trim() + +foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + Write-Host "==============================" + Write-Host "Processing language: $($dir.Name)" + Write-Host "==============================" + + $langCode = $dir.Name + + # Paths + $poFile = Join-Path $dir.FullName "$addonId.po" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + $xliffFile = Join-Path $dir.FullName "$addonId.xliff" + $targetDocDir = "addon/doc/$langCode" + $mdFile = Join-Path $targetDocDir "readme.md" + + # ---------------------------- + # SKIP ENGLISH (source language) + # ---------------------------- + if ($langCode -eq "en") { + Write-Host "Skipping English (source language) → no XLIFF processing required" + continue + } + + # ---------------------------- + # PO PROCESSING + # ---------------------------- + if (Test-Path $poFile) { + Write-Host "Checking PO file..." + uv run ./.github/scripts/checkTranslation.py "$poFile" + $isPoTranslated = ($LASTEXITCODE -eq 0) + Write-Host "PO translated: $isPoTranslated" + if ($isPoTranslated) { + Write-Host "Updating local PO" + New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null + Move-Item $poFile $localPoPath -Force + } else { + Write-Host "PO not translated" + if (Test-Path $localPoPath) { + Write-Host "Uploading local PO to Crowdin" + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon + } else { + Write-Host "No local PO available" + } + } + } + + # ---------------------------- + # XLIFF PROCESSING + # ---------------------------- + if (Test-Path $xliffFile) { + Write-Host "Checking XLIFF..." + uv run ./.github/scripts/checkTranslation.py "$xliffFile" + $isXliffTranslated = ($LASTEXITCODE -eq 0) + Write-Host "XLIFF translated: $isXliffTranslated" + if ($isXliffTranslated) { + Write-Host "Converting XLIFF → MD" + ./l10nUtil.exe xliff2md $xliffFile $mdFile + } else { + Write-Host "XLIFF not valid" + } + } else { + Write-Host "No XLIFF file found" + } +} # End foreach diff --git a/.github/scripts/setOutputs.py b/.github/scripts/getAddonInfo.py similarity index 64% rename from .github/scripts/setOutputs.py rename to .github/scripts/getAddonInfo.py index a5d9161..5006c6c 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/getAddonInfo.py @@ -11,11 +11,4 @@ def main(): addonId = buildVars.addon_info["addon_name"] - name = "addonId" - value = addonId - with open(os.environ["GITHUB_OUTPUT"], "a") as f: - _ = f.write(f"{name}={value}\n") - - -if __name__ == "__main__": - main() + print(addonId) From 87cbed41e5abe631081637e9192fa630c8e677f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:46:02 +0200 Subject: [PATCH 076/100] Run ps1 script from workflow --- .github/workflows/crowdinL10n.yml | 81 +------------------------------ 1 file changed, 1 insertion(+), 80 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 2d24165..a8fe391 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -48,86 +48,7 @@ jobs: - name: Download translations from Crowdin shell: pwsh run: | - ./l10nUtil.exe exportTranslations -o _addonL10n -c addon - - New-Item -ItemType Directory -Force -Path addon/locale | Out-Null - New-Item -ItemType Directory -Force -Path addon/doc | Out-Null - - $addonId = "${{ steps.getAddonInfo.outputs.addonId }}" - - foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - - Write-Host "==============================" - Write-Host "Processing language: $($dir.Name)" - Write-Host "==============================" - - $langCode = $dir.Name - $langShort = $langCode.Split('_')[0] - - # Paths - $poFile = Join-Path $dir.FullName "$addonId.po" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" - - $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $targetDocDir = "addon/doc/$langCode" - $mdFile = Join-Path $targetDocDir "readme.md" - - # ---------------------------- - # SKIP ENGLISH (source language) - # ---------------------------- - if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no XLIFF processing required" - continue - } - - # ---------------------------- - # PO PROCESSING - # ---------------------------- - if (Test-Path $poFile) { - Write-Host "Checking PO file..." - - uv run ./.github/scripts/checkTranslation.py "$poFile" - $isPoTranslated = ($LASTEXITCODE -eq 0) - - Write-Host "PO translated: $isPoTranslated" - - if ($isPoTranslated) { - Write-Host "Updating local PO" - New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null - Move-Item $poFile $localPoPath -Force - } else { - Write-Host "PO not translated" - if (Test-Path $localPoPath) { - Write-Host "Uploading local PO to Crowdin" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon - } else { - Write-Host "No local PO available" - } - } - } - - # ---------------------------- - # XLIFF PROCESSING - # ---------------------------- - - if (Test-Path $xliffFile) { - Write-Host "Checking XLIFF..." - - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) - Write-Host "XLIFF translated: $isXliffTranslated" - - if ($isXliffTranslated) { - Write-Host "Converting XLIFF → MD" - ./l10nUtil.exe xliff2md $xliffFile $mdFile - } - else { - Write-Host "XLIFF not valid" - } - else { - Write-Host "No XLIFF file found" - } - + ./.github/scripts/crowdinSync.ps1 # COMMIT # ---------------------------- From 1a18717bbe9f15d6791b5cc42f30ede1b66eceb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:48:51 +0200 Subject: [PATCH 077/100] Change python version to 3.13 --- .python-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.python-version b/.python-version index 2c45fe3..24ee5b1 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.13.11 +3.13 From 471a320d9b2195302f693d8fec65be117bd6cc3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 06:49:53 +0200 Subject: [PATCH 078/100] Update lock file --- uv.lock | 40 ---------------------------------------- 1 file changed, 40 deletions(-) diff --git a/uv.lock b/uv.lock index bb4070f..4bfb01f 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,6 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ - { name = "langid" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, @@ -23,7 +22,6 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "langid", specifier = "==1.1.6" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, @@ -74,15 +72,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] -[[package]] -name = "langid" -version = "1.1.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ea/4c/0fb7d900d3b0b9c8703be316fbddffecdab23c64e1b46c7a83561d78bd43/langid-1.1.6.tar.gz", hash = "sha256:044bcae1912dab85c33d8e98f2811b8f4ff1213e5e9a9e9510137b84da2cb293", size = 1925978, upload-time = "2016-04-05T22:34:15.786Z" } - [[package]] name = "lxml" version = "6.1.0" @@ -202,35 +191,6 @@ wheels = [ { 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" }, ] -[[package]] -name = "numpy" -version = "2.4.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, -] - [[package]] name = "platformdirs" version = "4.9.6" From e50a7169370ebbb34117db668960913399166ac9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 07:00:22 +0200 Subject: [PATCH 079/100] Fix checkTranslation script according to pre-commit --- .github/scripts/checkTranslation.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index eff95d3..95262b7 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -61,20 +61,10 @@ def main(): if len(sys.argv) < 2: print("Usage:") print(" checkTranslation.py ") - print(" checkTranslation.py ") - print(" checkTranslation.py [...] ") sys.exit(2) args = sys.argv[1:] - # ------------------------- - # MULTI FILE MODE - # ------------------------- - if len(args) >= 3: - *files, lang = args - compareMd(files, lang) - return - path = args[0] if not os.path.exists(path): @@ -99,20 +89,6 @@ def main(): print(f"translation_ratio={ratio}") sys.exit(0 if ratio > 0.05 else 1) - # ------------------------- - # MD (LANG SCORE) - # ------------------------- - elif ext == ".md": - if len(args) < 2: - print("Missing language argument for MD scoring") - sys.exit(2) - - lang = args[1] - score = scoreMd(path, lang) - - print(f"md_score={score}") - sys.exit(0) - else: print(f"Unsupported file type: {ext}") sys.exit(2) From f5150cb7a5a6cb0ac68b7f25f2eec13b63b52501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Fri, 24 Apr 2026 07:16:20 +0200 Subject: [PATCH 080/100] Remove getAddonInfo step --- .github/workflows/crowdinL10n.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index a8fe391..d13f572 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -36,11 +36,6 @@ jobs: - name: Install dependencies run: uv sync - - name: Get add-on info - id: getAddonInfo - shell: pwsh - run: uv run ./.github/scripts/setOutputs.py - - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" From 3c7093ccf8ab0c0779a5bac868757b48d652e931 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 05:02:21 +0200 Subject: [PATCH 081/100] Update scripts and workflow --- .github/scripts/crowdinSync.ps1 | 32 +++++++++++++++--- .../{getAddonInfo.py => setOutputs.py} | 9 ++++- .github/workflows/crowdinL10n.yml | 33 ++++++------------- 3 files changed, 46 insertions(+), 28 deletions(-) rename .github/scripts/{getAddonInfo.py => setOutputs.py} (65%) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 5bcff67..30da569 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,11 +1,16 @@ +#!/usr/bin/env pwsh +$ErrorActionPreference = 'Stop' + write-host "Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -Write-Host "Getting addon ID..." -$addonId = python ./.github/scripts/getAddonInfo.py 2>$null -$addonId = $addonId.Trim() +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." + exit 1 +} foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" @@ -65,9 +70,28 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "Converting XLIFF → MD" ./l10nUtil.exe xliff2md $xliffFile $mdFile } else { - Write-Host "XLIFF not valid" + Write-Host "XLIFF not translated" } } else { Write-Host "No XLIFF file found" } } # End foreach + + # COMMIT CHANGES + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git add addon/locale addon/doc + + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch $env:downloadTranslationsBranch 2>$null + + if ($LASTEXITCODE -ne 0) { + git switch -c $env:downloadTranslationsBranch + } + git push -f --set-upstream origin $env:downloadTranslationsBranch + } else { + Write-Host "Nothing to commit." + } \ No newline at end of file diff --git a/.github/scripts/getAddonInfo.py b/.github/scripts/setOutputs.py similarity index 65% rename from .github/scripts/getAddonInfo.py rename to .github/scripts/setOutputs.py index 5006c6c..a53aeb0 100644 --- a/.github/scripts/getAddonInfo.py +++ b/.github/scripts/setOutputs.py @@ -11,4 +11,11 @@ def main(): addonId = buildVars.addon_info["addon_name"] - print(addonId) + name = "addonId" + value = addonId + with open(os.environ["GITHUB_OUTPUT"], "a") as f: + f.write(f"{name}={value}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index d13f572..3fd37d8 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -3,6 +3,7 @@ name: Crowdin l10n on: workflow_dispatch: schedule: + # Every Monday at 00:00 UTC - cron: '0 0 * * 1' concurrency: @@ -19,6 +20,7 @@ jobs: runs-on: windows-latest permissions: contents: write + steps: - name: Checkout add-on uses: actions/checkout@v6 @@ -36,32 +38,17 @@ jobs: - name: Install dependencies run: uv sync + - name: Get add-on info + id: getAddonInfo + shell: pwsh + run: uv run ./.github/scripts/setOutputs.py + - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - name: Download translations from Crowdin shell: pwsh - run: | - ./.github/scripts/crowdinSync.ps1 - - # COMMIT - # ---------------------------- - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - git add addon/locale addon/doc - - git diff --staged --quiet - if ($LASTEXITCODE -ne 0) { - git commit -m "Update translations for $addonId from Crowdin" - - git switch ${{ env.downloadTranslationsBranch }} 2>$null - if ($LASTEXITCODE -ne 0) { - git switch -c ${{ env.downloadTranslationsBranch }} - } - - git push -f --set-upstream origin ${{ env.downloadTranslationsBranch }} - } else { - Write-Host "Nothing to commit." - } + env: + ADDON_ID: ${{ steps.getAddonInfo.outputs.addonId }} + run: ./.github/scripts/crowdinSync.ps1 From 065a16fa87ba79aa352c44a1ac62565a656710a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 09:32:14 +0200 Subject: [PATCH 082/100] Fix pyright --- .github/scripts/crowdinSync.ps1 | 2 +- .github/scripts/setOutputs.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 30da569..418d97a 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -94,4 +94,4 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { git push -f --set-upstream origin $env:downloadTranslationsBranch } else { Write-Host "Nothing to commit." - } \ No newline at end of file + } diff --git a/.github/scripts/setOutputs.py b/.github/scripts/setOutputs.py index a53aeb0..a5d9161 100644 --- a/.github/scripts/setOutputs.py +++ b/.github/scripts/setOutputs.py @@ -14,7 +14,7 @@ def main(): name = "addonId" value = addonId with open(os.environ["GITHUB_OUTPUT"], "a") as f: - f.write(f"{name}={value}\n") + _ = f.write(f"{name}={value}\n") if __name__ == "__main__": From c4dd5210304d1e2ee417edc1f1288b3da0c450d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 22:21:57 +0200 Subject: [PATCH 083/100] Add markdownTranslate --- .github/scripts/markdownTranslate.py | 881 +++++++++++++++++++++++++++ 1 file changed, 881 insertions(+) create mode 100644 .github/scripts/markdownTranslate.py diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py new file mode 100644 index 0000000..e276049 --- /dev/null +++ b/.github/scripts/markdownTranslate.py @@ -0,0 +1,881 @@ +# A part of NonVisual Desktop Access (NVDA) +# Copyright (C) 2024 NV Access Limited. +# This file is covered by the GNU General Public License. +# See the file COPYING for more details. + +from typing 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}") From 00cf8cf29b6df42fa20ea124eda32ea68963703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sat, 25 Apr 2026 22:28:56 +0200 Subject: [PATCH 084/100] Update markdownTranslate --- .github/scripts/markdownTranslate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/markdownTranslate.py b/.github/scripts/markdownTranslate.py index e276049..d9cbaf5 100644 --- a/.github/scripts/markdownTranslate.py +++ b/.github/scripts/markdownTranslate.py @@ -3,7 +3,7 @@ # This file is covered by the GNU General Public License. # See the file COPYING for more details. -from typing import Generator +from collections.abc import Generator from collections.abc import Iterable import tempfile import os From 480c70e5f087a5f1594c3ad2b089674495c2a7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 03:41:06 +0200 Subject: [PATCH 085/100] Exclude markdownTranslate from pyright, it contains many errors --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 544accf..7791208 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,6 +104,7 @@ exclude = [ # When excluding concrete paths relative to a directory, # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. + ".github/scripts/markdownTranslate.py", ] # Tell pyright where to load python code from From ce2d2da38286f1c15b68548387c1acbdf99d4c06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 06:58:05 +0200 Subject: [PATCH 086/100] Update source files --- .github/scripts/crowdinSync.ps1 | 56 ++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 418d97a..9b6a719 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -12,6 +12,7 @@ if (-not $addonId) { exit 1 } +# Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" Write-Host "Processing language: $($dir.Name)" @@ -77,21 +78,46 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { } } # End foreach - # COMMIT CHANGES - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" +# COMMIT CHANGES +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" - git add addon/locale addon/doc +git add addon/locale addon/doc - git diff --staged --quiet - if ($LASTEXITCODE -ne 0) { - git commit -m "Update translations for $addonId from Crowdin" - git switch $env:downloadTranslationsBranch 2>$null +git diff --staged --quiet +if ($LASTEXITCODE -ne 0) { + git commit -m "Update translations for $addonId from Crowdin" + git switch $env:downloadTranslationsBranch 2>$null - if ($LASTEXITCODE -ne 0) { - git switch -c $env:downloadTranslationsBranch - } - git push -f --set-upstream origin $env:downloadTranslationsBranch - } else { - Write-Host "Nothing to commit." - } + if ($LASTEXITCODE -ne 0) { + git switch -c $env:downloadTranslationsBranch + } + git push -f --set-upstream origin $env:downloadTranslationsBranch +} else { + Write-Host "Nothing to commit." +} + +# Update xliff +$xlifFile = "$addonId.xliff" +$mdFile = "./readme.md" +if ((Test-Path $xlifFile) -and (Test-Path $mdFile)) { + $tempXliff = [System.IO.Path]::GetTempFileName() + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + Write-Host "Updated $xlifFile based on $mdFile" + Write-Host "Uploading updated XLIFF to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$xlifFile" -c addon +} else { + Write-Host "Documentation files not found, skipping xliff update." +} + +# Update pot file +scons pot +$potFile = "$addonId.pot" +if (Test-Path $potFile) { + Write-Host "Uploading updated POT to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$potFile" -c addon +} else { + Write-Host "POT file not found, skipping POT update." +} From 3981eceb009cc4154b082b30c17e965f74bd4d37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 08:53:48 +0200 Subject: [PATCH 087/100] Updates --- .github/scripts/crowdinSync.ps1 | 89 +++++++++++++++++++------------ .github/workflows/crowdinL10n.yml | 7 ++- 2 files changed, 61 insertions(+), 35 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 9b6a719..6e86ec3 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,17 +1,67 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' +# Config git +git config user.name "github-actions[bot]" +git config user.email "github-actions[bot]@users.noreply.github.com" -write-host "Exporting translations from Crowdin..." -./l10nUtil.exe exportTranslations -o _addonL10n -c addon - -New-Item -ItemType Directory -Force -Path addon/locale | Out-Null -New-Item -ItemType Directory -Force -Path addon/doc | Out-Null $addonId = $env:ADDON_ID.Trim() if (-not $addonId) { Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." exit 1 } +# Update xliff file +$xliffFile = "./$addonId.xliff" +$mdFile = "./readme.md" +if (Test-Path $mdFile) { + if (Test-Path $xliffFile) { + $tempXliff = [System.IO.Path]::GetTempFileName() + Copy-Item "$addonId.xliff" $tempXliff -Force + Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + Write-Host "Updated $xliffFile based on $mdFile" + } else { + Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." + uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile + } +} else { + Write-Host "readme.md not found. Skipping XLIFF generation." +} + +# Update pot file in Crowdin +uv run scons pot +$potFile = "$addonId.pot" +if (Test-Path $potFile) { + Write-Host "Uploading updated POT to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$potFile" -c addon +} else { + Write-Host "POT file not found, skipping POT update." +} + +# Update xliff file in Crowdin +if (Test-Path $xliffFile) { + Write-Host "Uploading XLIFF to Crowdin..." + ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon + git add "$xliffFile" + git diff --staged --quiet + if ($LASTEXITCODE -ne 0) { + git commit -m "Update $xliffFile for $addonId" + git push + } else { + Write-Host "No changes to $xliffFile, skipping commit." + } +} else { + Write-Host "XLIFF file not found, skipping XLIFF upload." +} + +# Export translations +write-host "Exporting translations from Crowdin..." +./l10nUtil.exe exportTranslations -o _addonL10n -c addon + +New-Item -ItemType Directory -Force -Path addon/locale | Out-Null +New-Item -ItemType Directory -Force -Path addon/doc | Out-Null + + # Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { Write-Host "==============================" @@ -79,11 +129,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { } # End foreach # COMMIT CHANGES -git config user.name "github-actions[bot]" -git config user.email "github-actions[bot]@users.noreply.github.com" - git add addon/locale addon/doc - git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" @@ -96,28 +142,3 @@ if ($LASTEXITCODE -ne 0) { } else { Write-Host "Nothing to commit." } - -# Update xliff -$xlifFile = "$addonId.xliff" -$mdFile = "./readme.md" -if ((Test-Path $xlifFile) -and (Test-Path $mdFile)) { - $tempXliff = [System.IO.Path]::GetTempFileName() - Copy-Item "$addonId.xliff" $tempXliff -Force - Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile - Write-Host "Updated $xlifFile based on $mdFile" - Write-Host "Uploading updated XLIFF to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$xlifFile" -c addon -} else { - Write-Host "Documentation files not found, skipping xliff update." -} - -# Update pot file -scons pot -$potFile = "$addonId.pot" -if (Test-Path $potFile) { - Write-Host "Uploading updated POT to Crowdin..." - ./l10nUtil.exe uploadSourceFile "$potFile" -c addon -} else { - Write-Host "POT file not found, skipping POT update." -} diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 3fd37d8..82a3bd8 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -37,7 +37,12 @@ jobs: - name: Install dependencies run: uv sync - + - name: Install gettext + run: | + choco install -y gettext + # Add gettext to PATH for current and future steps + $gettextPath = "C:\Program Files\gettext-iconv\bin" + echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Get add-on info id: getAddonInfo shell: pwsh From 0def05dbbfc39a4b2c8fd5f468a195e3615a0b0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 09:50:16 +0200 Subject: [PATCH 088/100] Fix files with pre-commit --- .github/scripts/crowdinSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 6e86ec3..53b3e62 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -18,7 +18,7 @@ if (Test-Path $mdFile) { $tempXliff = [System.IO.Path]::GetTempFileName() Copy-Item "$addonId.xliff" $tempXliff -Force Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" - uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile + uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile Write-Host "Updated $xliffFile based on $mdFile" } else { Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." From 1c92aab38c5f15ada8afd09c1d43c4e9ffbd4428 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Sun, 26 Apr 2026 13:36:06 +0200 Subject: [PATCH 089/100] refactor: use Crowdin API for translation checks and optimize file selection - Replace langid and polib dependencies with direct Crowdin API calls. - Implement smart mapping between local filenames and Crowdin sources. - Add support for comparing .md and .xliff documentation files. - Prioritize XLIFF format and select the file with the highest translation ratio. - Standardize script output for poScore, mdScore, and translationRatio. --- .github/scripts/checkTranslation.py | 150 +++++++++++++--------------- .github/scripts/crowdinSync.ps1 | 135 ++++++++++++------------- .github/workflows/crowdinL10n.yml | 8 +- pyproject.toml | 1 - 4 files changed, 139 insertions(+), 155 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 95262b7..f13b177 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -1,98 +1,90 @@ import sys import os -import xml.etree.ElementTree as ET -import polib - - -def normalize(s: str | None) -> str: - return " ".join((s or "").strip().lower().split()) - +from crowdin_api import CrowdinClient # ----------------------------- -# PO CHECK +# CROWDIN API SCORE # ----------------------------- - -def checkPo(path: str) -> float: - po = polib.pofile(path) - translated = 0 - total = 0 - - for entry in po: - if not entry.msgid.strip(): - continue - - total += 1 - - if entry.msgstr and normalize(entry.msgstr) != normalize(entry.msgid): - translated += 1 - - return translated / total if total else 0.0 - +def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: + """ + Fetches the translation progress percentage directly from Crowdin API. + Returns a float between 0.0 and 1.0. + """ + token = os.environ.get("crowdinAuthToken") + project_id_env = os.environ.get("CROWDIN_PROJECT_ID") + + # Ensure credentials are present + if not token or not project_id_env: + return 0.0 + + client = CrowdinClient(token=token) + project_id = int(project_id_env) + + try: + # 1. NORMALIZE SEARCH TERMS + # Extract base name (e.g., 'askOpenRouter') and extension + base_target = crowdin_file_name.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() + ext_target = crowdin_file_name.split('.')[-1].lower() + + # Mapping: if we check a .po, we look for a .pot on Crowdin + search_ext = ".pot" if ext_target == "po" else f".{ext_target}" + + # 2. FETCH ALL FILES TO FIND MATCHING ID + files = client.source_files.list_files(projectId=project_id, limit=500) + file_id = None + + for f in files['data']: + path_crowdin = f['data']['path'].lower() + if path_crowdin.endswith(f"{base_target}{search_ext}"): + file_id = f['data']['id'] + break + + if file_id is None: + return 0.0 + + # 3. FETCH PROGRESS FOR THE SPECIFIC FILE + # We use get_file_progress which is reliable for specific file IDs + progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) + + for item in progress['data']: + if item['data']['languageId'].lower() == lang_id.lower(): + # Return ratio (0.0 to 1.0) + return float(item['data']['translationProgress']) / 100 + + except Exception: + # Fallback to 0.0 in case of API or network error + return 0.0 + + return 0.0 # ----------------------------- -# XLIFF CHECK +# MAIN ENGINE # ----------------------------- - -def checkXliff(path: str) -> float: - tree = ET.parse(path) - root = tree.getroot() - translated = 0 - total = 0 - source = None - - for elem in root.iter(): - if elem.tag.endswith("source"): - source = normalize(elem.text) - - elif elem.tag.endswith("target"): - target = normalize(elem.text) - - if source: - total += 1 - if target and target != source: - translated += 1 - - return translated / total if total else 0.0 - - def main(): - if len(sys.argv) < 2: - print("Usage:") - print(" checkTranslation.py ") + """ + Main entry point. + Expects two arguments: + """ + if len(sys.argv) < 3: sys.exit(2) - args = sys.argv[1:] - - path = args[0] - - if not os.path.exists(path): - print(f"File not found: {path}") - sys.exit(2) - - ext = os.path.splitext(path)[1].lower() - - # ------------------------- - # PO - # ------------------------- - if ext == ".po": - ratio = checkPo(path) - print(f"translation_ratio={ratio}") - sys.exit(0 if ratio > 0.05 else 1) - - # ------------------------- - # XLIFF - # ------------------------- - elif ext in [".xliff", ".xlf"]: - ratio = checkXliff(path) - print(f"translation_ratio={ratio}") - sys.exit(0 if ratio > 0.05 else 1) + file_name = sys.argv[1] + lang = sys.argv[2] + # All evaluations now go through the Crowdin API + score = get_score_from_api(lang, file_name) + + # Output formatting for PowerShell capture + print(f"translationRatio={score}") + if file_name.lower().endswith('.md'): + print(f"mdScore={score}") else: - print(f"Unsupported file type: {ext}") - sys.exit(2) + print(f"poScore={score}") + # Exit with code 0 if score > 5%, otherwise 1 + sys.exit(0 if score > 0.05 else 1) if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 53b3e62..59d6c65 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,15 +1,10 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' + # Config git git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" -$addonId = $env:ADDON_ID.Trim() -if (-not $addonId) { - Write-Error "Failed to get addon ID. Ensure buildVars.py and dependencies are present." - exit 1 -} - # Update xliff file $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" @@ -55,90 +50,92 @@ if (Test-Path $xliffFile) { } # Export translations -write-host "Exporting translations from Crowdin..." +Write-Host "Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon +# Ensure base directories exist New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID." + exit 1 +} -# Process each language directory foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - Write-Host "==============================" - Write-Host "Processing language: $($dir.Name)" - Write-Host "==============================" - $langCode = $dir.Name + $langShort = $langCode.Split('_')[0] - # Paths - $poFile = Join-Path $dir.FullName "$addonId.po" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + if ($langCode -eq "en") { continue } - $xliffFile = Join-Path $dir.FullName "$addonId.xliff" - $targetDocDir = "addon/doc/$langCode" - $mdFile = Join-Path $targetDocDir "readme.md" + Write-Host "--- Processing: $addonId ($langCode) ---" - # ---------------------------- - # SKIP ENGLISH (source language) - # ---------------------------- - if ($langCode -eq "en") { - Write-Host "Skipping English (source language) → no XLIFF processing required" - continue - } + # Temporary files from Crowdin + $remoteMd = Join-Path $dir.FullName "$addonId.md" + $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" + $remotePo = Join-Path $dir.FullName "$addonId.po" - # ---------------------------- - # PO PROCESSING - # ---------------------------- - if (Test-Path $poFile) { - Write-Host "Checking PO file..." - uv run ./.github/scripts/checkTranslation.py "$poFile" - $isPoTranslated = ($LASTEXITCODE -eq 0) - Write-Host "PO translated: $isPoTranslated" - if ($isPoTranslated) { - Write-Host "Updating local PO" + # Local paths + $localMdDir = "addon/doc/$langCode" + $localMd = "$localMdDir/readme.md" + $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + + # 1. PO PROCESSING + if (Test-Path $remotePo) { + uv run python .github/scripts/checkTranslation.py "$addonId.po" $langShort + if ($LASTEXITCODE -eq 0) { New-Item -ItemType Directory -Force -Path (Split-Path $localPoPath) | Out-Null - Move-Item $poFile $localPoPath -Force - } else { - Write-Host "PO not translated" - if (Test-Path $localPoPath) { - Write-Host "Uploading local PO to Crowdin" - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.po" $localPoPath -c addon - } else { - Write-Host "No local PO available" - } + Move-Item $remotePo $localPoPath -Force } } - # ---------------------------- - # XLIFF PROCESSING - # ---------------------------- - if (Test-Path $xliffFile) { - Write-Host "Checking XLIFF..." - uv run ./.github/scripts/checkTranslation.py "$xliffFile" - $isXliffTranslated = ($LASTEXITCODE -eq 0) - Write-Host "XLIFF translated: $isXliffTranslated" - if ($isXliffTranslated) { - Write-Host "Converting XLIFF → MD" - ./l10nUtil.exe xliff2md $xliffFile $mdFile + # 2. EVALUATION VIA API + $scoreMd = 0.0 + $scoreXliff = 0.0 + + if (Test-Path $remoteMd) { + $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $langShort + $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] + } + + if (Test-Path $remoteXliff) { + $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $langShort + $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] + } + + Write-Host "Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + + # 3. DECISION LOGIC + $threshold = 0.5 + $imported = $false + + if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { + # Create doc directory if needed + if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } + + if ($scoreXliff -ge $scoreMd) { + Write-Host "Action: Converting XLIFF to local MD" + ./l10nUtil.exe xliff2md $remoteXliff $localMd + $imported = $true } else { - Write-Host "XLIFF not translated" + Write-Host "Action: Importing Remote MD to local" + Move-Item $remoteMd $localMd -Force + $imported = $true } - } else { - Write-Host "No XLIFF file found" } -} # End foreach -# COMMIT CHANGES + # 4. FALLBACK: Upload local if remote is poor + if (-not $imported -and (Test-Path $localMd)) { + Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." + ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + } +} + git add addon/locale addon/doc git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" - git switch $env:downloadTranslationsBranch 2>$null - - if ($LASTEXITCODE -ne 0) { - git switch -c $env:downloadTranslationsBranch - } - git push -f --set-upstream origin $env:downloadTranslationsBranch -} else { - Write-Host "Nothing to commit." -} + $branch = $env:downloadTranslationsBranch + git push -f origin "HEAD:$branch" +} \ No newline at end of file diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 82a3bd8..0615e63 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -14,6 +14,7 @@ env: crowdinAuthToken: ${{ secrets.CROWDIN_TOKEN }} downloadTranslationsBranch: l10n GH_TOKEN: ${{ github.token }} + CROWDIN_PROJECT_ID: 780748 jobs: crowdinSync: @@ -37,12 +38,7 @@ jobs: - name: Install dependencies run: uv sync - - name: Install gettext - run: | - choco install -y gettext - # Add gettext to PATH for current and future steps - $gettextPath = "C:\Program Files\gettext-iconv\bin" - echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: Get add-on info id: getAddonInfo shell: pwsh diff --git a/pyproject.toml b/pyproject.toml index 7791208..ee2c588 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,6 @@ dependencies = [ "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", "mdx-gh-links==0.4", - "polib==1.2.0", # Lint "uv==0.11.6", "ruff==0.14.5", From c5e4076bb57cd095b45f4bb8a818e0e5e120f2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:32:22 +0200 Subject: [PATCH 090/100] Add crowdin api client --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index ee2c588..d5fe2f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management + "crowdin-api-client==1.24.1", "nh3==0.3.2", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", From c0983924e0ab0b6ed38b0a475b911eab73017a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:34:27 +0200 Subject: [PATCH 091/100] Add dependencies --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d5fe2f1..55e4001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,8 +25,9 @@ dependencies = [ "scons==4.10.1", "Markdown==3.10", # Translations management - "crowdin-api-client==1.24.1", + "requests==2.33.0", "nh3==0.3.2", + "crowdin-api-client==1.24.1", "lxml==6.1.0", "mdx_truly_sane_lists==1.3", "markdown-link-attr-modifier==0.2.1", From 39416a0ddfe7a82176950617170f23832dc892bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:47:35 +0200 Subject: [PATCH 092/100] Remove limit for list of files, fetch all --- .github/scripts/checkTranslation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index f13b177..c244595 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -31,7 +31,7 @@ def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: search_ext = ".pot" if ext_target == "po" else f".{ext_target}" # 2. FETCH ALL FILES TO FIND MATCHING ID - files = client.source_files.list_files(projectId=project_id, limit=500) + files = client.source_files.with_fetch_all().list_files(project_id) file_id = None for f in files['data']: From 23968642626848a60c91a90202a94105ceddbf28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 16:57:22 +0200 Subject: [PATCH 093/100] Improvements --- .github/scripts/checkTranslation.py | 40 ++++---- .github/scripts/crowdinSync.ps1 | 2 +- pyproject.toml | 1 + uv.lock | 138 +++++++++++++++++++++++++--- 4 files changed, 151 insertions(+), 30 deletions(-) diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index c244595..7ca92cd 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -6,6 +6,7 @@ # CROWDIN API SCORE # ----------------------------- + def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: """ Fetches the translation progress percentage directly from Crowdin API. @@ -24,47 +25,49 @@ def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: try: # 1. NORMALIZE SEARCH TERMS # Extract base name (e.g., 'askOpenRouter') and extension - base_target = crowdin_file_name.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() - ext_target = crowdin_file_name.split('.')[-1].lower() - + base_target = crowdin_file_name.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() + ext_target = crowdin_file_name.split(".")[-1].lower() + # Mapping: if we check a .po, we look for a .pot on Crowdin search_ext = ".pot" if ext_target == "po" else f".{ext_target}" - + # 2. FETCH ALL FILES TO FIND MATCHING ID files = client.source_files.with_fetch_all().list_files(project_id) file_id = None - - for f in files['data']: - path_crowdin = f['data']['path'].lower() + + for f in files["data"]: + path_crowdin = f["data"]["path"].lower() if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f['data']['id'] + file_id = f["data"]["id"] break - + if file_id is None: return 0.0 # 3. FETCH PROGRESS FOR THE SPECIFIC FILE # We use get_file_progress which is reliable for specific file IDs progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) - - for item in progress['data']: - if item['data']['languageId'].lower() == lang_id.lower(): + + for item in progress["data"]: + if item["data"]["languageId"].lower() == lang_id.lower(): # Return ratio (0.0 to 1.0) - return float(item['data']['translationProgress']) / 100 - + return float(item["data"]["translationProgress"]) / 100 + except Exception: # Fallback to 0.0 in case of API or network error return 0.0 - + return 0.0 + # ----------------------------- # MAIN ENGINE # ----------------------------- + def main(): """ - Main entry point. + Main entry point. Expects two arguments: """ if len(sys.argv) < 3: @@ -75,10 +78,10 @@ def main(): # All evaluations now go through the Crowdin API score = get_score_from_api(lang, file_name) - + # Output formatting for PowerShell capture print(f"translationRatio={score}") - if file_name.lower().endswith('.md'): + if file_name.lower().endswith(".md"): print(f"mdScore={score}") else: print(f"poScore={score}") @@ -86,5 +89,6 @@ def main(): # Exit with code 0 if score > 5%, otherwise 1 sys.exit(0 if score > 0.05 else 1) + if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 59d6c65..2f98fea 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -138,4 +138,4 @@ if ($LASTEXITCODE -ne 0) { git commit -m "Update translations for $addonId from Crowdin" $branch = $env:downloadTranslationsBranch git push -f origin "HEAD:$branch" -} \ No newline at end of file +} diff --git a/pyproject.toml b/pyproject.toml index 55e4001..9fe5dd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -106,6 +106,7 @@ exclude = [ # not matching multiple folders by name e.g. `__pycache__`, # paths are relative to the configuration file. ".github/scripts/markdownTranslate.py", + ".github/scripts/checkTranslation.py", ] # Tell pyright where to load python code from diff --git a/uv.lock b/uv.lock index 4bfb01f..1969d46 100644 --- a/uv.lock +++ b/uv.lock @@ -6,15 +6,16 @@ requires-python = "==3.13.*" name = "addontemplate" source = { editable = "." } dependencies = [ + { name = "crowdin-api-client" }, { name = "lxml" }, { name = "markdown" }, { name = "markdown-link-attr-modifier" }, { name = "mdx-gh-links" }, { name = "mdx-truly-sane-lists" }, { name = "nh3" }, - { name = "polib" }, { name = "pre-commit" }, { name = "pyright", extra = ["nodejs"] }, + { name = "requests" }, { name = "ruff" }, { name = "scons" }, { name = "uv" }, @@ -22,20 +23,30 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "crowdin-api-client", specifier = "==1.24.1" }, { name = "lxml", specifier = "==6.1.0" }, { name = "markdown", specifier = "==3.10" }, { name = "markdown-link-attr-modifier", specifier = "==0.2.1" }, { name = "mdx-gh-links", specifier = "==0.4" }, { name = "mdx-truly-sane-lists", specifier = "==1.3" }, { name = "nh3", specifier = "==0.3.2" }, - { name = "polib", specifier = "==1.2.0" }, { name = "pre-commit", specifier = "==4.2.0" }, { name = "pyright", extras = ["nodejs"], specifier = "==1.1.407" }, + { name = "requests", specifier = "==2.33.0" }, { name = "ruff", specifier = "==0.14.5" }, { name = "scons", specifier = "==4.10.1" }, { name = "uv", specifier = "==0.11.6" }, ] +[[package]] +name = "certifi" +version = "2026.4.22" +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" } +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" }, +] + [[package]] name = "cfgv" version = "3.5.0" @@ -45,6 +56,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "crowdin-api-client" +version = "1.24.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/fc/ec5564928057aac9cae7e78ed324898b3134369b100bbb2b5c97ad1ad548/crowdin_api_client-1.24.1.tar.gz", hash = "sha256:d2a385c2b3f8e985d5bb084524ae14aef9045094fba0b2df1df82d9da97155b1", size = 70629, upload-time = "2025-08-26T13:20:34.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/74/118d8f5e592a1fe75b793346a599d57746b18b8875c31e956022b63ba173/crowdin_api_client-1.24.1-py3-none-any.whl", hash = "sha256:a07365a2a0d42830ee4eb188e3820603e1420421575637b1ddd8dffe1d2fe14c", size = 109654, upload-time = "2025-08-26T13:20:33.673Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -72,6 +133,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] +[[package]] +name = "idna" +version = "3.13" +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" } +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" }, +] + [[package]] name = "lxml" version = "6.1.0" @@ -200,15 +270,6 @@ 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" }, ] -[[package]] -name = "polib" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/10/9a/79b1067d27e38ddf84fe7da6ec516f1743f31f752c6122193e7bce38bdbf/polib-1.2.0.tar.gz", hash = "sha256:f3ef94aefed6e183e342a8a269ae1fc4742ba193186ad76f175938621dbfc26b", size = 161658, upload-time = "2023-02-23T17:53:56.873Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6b/99/45bb1f9926efe370c6dbe324741c749658e44cb060124f28dad201202274/polib-1.2.0-py2.py3-none-any.whl", hash = "sha256:1c77ee1b81feb31df9bca258cbc58db1bbb32d10214b173882452c73af06d62d", size = 20634, upload-time = "2023-02-23T17:53:59.919Z" }, -] - [[package]] name = "pre-commit" version = "4.2.0" @@ -274,6 +335,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { 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" } +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" }, +] + [[package]] name = "ruff" version = "0.14.5" @@ -318,6 +394,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "urllib3" +version = "2.6.3" +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" } +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" }, +] + [[package]] name = "uv" version = "0.11.6" @@ -358,3 +443,34 @@ sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133 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" }, ] + +[[package]] +name = "wrapt" +version = "2.1.2" +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" } +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" }, +] From 94929e793dcd728da22fd396c0cf60a9809387f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Sun, 26 Apr 2026 17:28:37 +0200 Subject: [PATCH 094/100] Updates --- .github/scripts/crowdinSync.ps1 | 12 ++++++------ .github/workflows/crowdinL10n.yml | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 2f98fea..bfff2c9 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -5,6 +5,12 @@ $ErrorActionPreference = 'Stop' git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" +$addonId = $env:ADDON_ID.Trim() +if (-not $addonId) { + Write-Error "Failed to get addon ID." + exit 1 +} + # Update xliff file $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" @@ -57,12 +63,6 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -$addonId = $env:ADDON_ID.Trim() -if (-not $addonId) { - Write-Error "Failed to get addon ID." - exit 1 -} - foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { $langCode = $dir.Name $langShort = $langCode.Split('_')[0] diff --git a/.github/workflows/crowdinL10n.yml b/.github/workflows/crowdinL10n.yml index 0615e63..8ec5efc 100644 --- a/.github/workflows/crowdinL10n.yml +++ b/.github/workflows/crowdinL10n.yml @@ -27,27 +27,27 @@ jobs: uses: actions/checkout@v6 with: submodules: true - - name: Set up Python uses: actions/setup-python@v6 with: python-version-file: ".python-version" - - name: Install uv uses: astral-sh/setup-uv@v7 - - name: Install dependencies run: uv sync - + - name: Install gettext + run: | + choco install -y gettext + # Add gettext to PATH for current and future steps + $gettextPath = "C:\Program Files\gettext-iconv\bin" + echo "$gettextPath" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: Get add-on info id: getAddonInfo shell: pwsh run: uv run ./.github/scripts/setOutputs.py - - name: Download l10nUtil run: | gh release download --repo nvaccess/nvdaL10n --pattern "l10nUtil.exe" - - name: Download translations from Crowdin shell: pwsh env: From 57d46f550b1ba6ba50d20aed74464179eaf8340f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 27 Apr 2026 18:45:30 +0200 Subject: [PATCH 095/100] Add language mappings --- .github/scripts/crowdinSync.ps1 | 10 ++++++---- .github/scripts/languageMappings.json | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .github/scripts/languageMappings.json diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index bfff2c9..873d45f 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -63,12 +63,14 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +$languageMappings = Get-Content".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name - $langShort = $langCode.Split('_')[0] + $langCode = $dir.Name if ($langCode -eq "en") { continue } - + $crowdinLang = $languageMappings[$langCode] + if (-not $crowdinLang) { $crowdinLang = $langCode } + $langShort = $langCode.Split('_')[0] Write-Host "--- Processing: $addonId ($langCode) ---" # Temporary files from Crowdin @@ -128,7 +130,7 @@ foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { # 4. FALLBACK: Upload local if remote is poor if (-not $imported -and (Test-Path $localMd)) { Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." - ./l10nUtil.exe uploadTranslationFile $langCode "$addonId.md" $localMd -c addon + ./l10nUtil.exe uploadTranslationFile $crowdinLang "$addonId.md" $localMd -c addon } } diff --git a/.github/scripts/languageMappings.json b/.github/scripts/languageMappings.json new file mode 100644 index 0000000..cae6e13 --- /dev/null +++ b/.github/scripts/languageMappings.json @@ -0,0 +1,14 @@ +{ + "af_ZA": "af", + "de_CH": "de-CH", + "es": "es-ES", + "es_CO": "es-CO", + "nb_NO": "nb", + "nn_NO": "nn-NO", + "pt_PT": "pt-PT", + "pt_BR": "pt-BR", + "sr": "sr-CS", + "zh_CN": "zh-CN", + "zh_HK": "zh-HK", + "zh_TW": "zh-TW" +} From 74388d089ccbc39e816228b33056bd547eb752fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Mon, 27 Apr 2026 19:34:59 +0200 Subject: [PATCH 096/100] Fix --- .github/scripts/crowdinSync.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 873d45f..0237ef1 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -63,7 +63,7 @@ Write-Host "Exporting translations from Crowdin..." New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null -$languageMappings = Get-Content".github/scripts/languageMappings.json" | ConvertFrom-Json +$languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { $langCode = $dir.Name From 1f8eb842bd7a7d349892832d77b275e8544b4079 Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 07:44:30 +0200 Subject: [PATCH 097/100] refactor: modernize translation workflow and integrate language mappings This commit significantly improves the synchronization process between Crowdin and the local repository, with a primary focus on the integration of the 'languageMappings.json' file originally implemented by @nvdaes. Key changes include: - Integrated languageMappings.json: Now used as the source of truth for Crowdin-specific language identifiers during the upload process. - Added langCodes.py: Implemented a symmetrical Python helper to map Crowdin export codes back to standard NVDA directory names (e.g., handling 'es-ES' -> 'es', 'ar-SA' -> 'ar_SA', 'sr-CS' -> 'sr'). - Fixed API Pagination: Updated checkTranslation.py to handle recursive API calls. This ensures all languages (including those beyond the initial 25 results, such as Turkish) are correctly processed and scored. - Enhanced crowdinSync.ps1: * Implemented bi-directional synchronization to upload local legacy files (.po and .md) when remote translation quality is low. * Added comprehensive debug and success logging for documentation conversions (XLIFF/MD) and scoring. * Improved decision logic for readme.md updates based on score comparison. Special thanks to @nvdaes for the mapping implementation. --- .github/scripts/checkTranslation.py | 133 +++++++++++++++++----------- .github/scripts/crowdinSync.ps1 | 107 +++++++++++++--------- .github/scripts/langCodes.py | 60 +++++++++++++ 3 files changed, 207 insertions(+), 93 deletions(-) create mode 100644 .github/scripts/langCodes.py diff --git a/.github/scripts/checkTranslation.py b/.github/scripts/checkTranslation.py index 7ca92cd..b067d6d 100644 --- a/.github/scripts/checkTranslation.py +++ b/.github/scripts/checkTranslation.py @@ -2,93 +2,124 @@ import os from crowdin_api import CrowdinClient -# ----------------------------- -# CROWDIN API SCORE -# ----------------------------- +def find_file_id(client, project_id, base_target, search_ext): + """ + Iterates through all project files (using pagination) to find the ID + of the source file matching the target name and extension. + """ + offset = 0 + limit = 100 + + while True: + resp = client.source_files.list_files( + projectId=project_id, + limit=limit, + offset=offset + ) + + data = resp['data'] + for f in data: + path_crowdin = f['data']['path'].lower() + # Check if the path ends with addon_id.pot or addon_id.xliff + if path_crowdin.endswith(f"{base_target}{search_ext}"): + file_id = f['data']['id'] + print(f"DEBUG: Match found: {path_crowdin} (ID: {file_id})") + return file_id + + if len(data) < limit: + break + + offset += limit + return None -def get_score_from_api(lang_id: str, crowdin_file_name: str) -> float: +def get_score_from_api(file_name_to_search: str, lang_id: str) -> float: """ - Fetches the translation progress percentage directly from Crowdin API. - Returns a float between 0.0 and 1.0. + Retrieves the translation progress score for a specific language and file. + Handles pagination for both file listing and language status. """ token = os.environ.get("crowdinAuthToken") - project_id_env = os.environ.get("CROWDIN_PROJECT_ID") + p_id_env = os.environ.get("CROWDIN_PROJECT_ID") - # Ensure credentials are present - if not token or not project_id_env: + if not token or not p_id_env: + print("ERROR: Missing environment variables 'crowdinAuthToken' or 'CROWDIN_PROJECT_ID'.") return 0.0 client = CrowdinClient(token=token) - project_id = int(project_id_env) + p_id = int(p_id_env) try: - # 1. NORMALIZE SEARCH TERMS - # Extract base name (e.g., 'askOpenRouter') and extension - base_target = crowdin_file_name.replace("\\", "/").split("/")[-1].rsplit(".", 1)[0].lower() - ext_target = crowdin_file_name.split(".")[-1].lower() + # Clean and prepare search patterns + # Example: 'addon/locale/fr/LC_MESSAGES/myaddon.po' -> base_target: 'myaddon' + base_target = file_name_to_search.replace('\\', '/').split('/')[-1].rsplit('.', 1)[0].lower() + ext_target = file_name_to_search.split('.')[-1].lower() - # Mapping: if we check a .po, we look for a .pot on Crowdin + # On Crowdin, the source for a .po file is usually a .pot file search_ext = ".pot" if ext_target == "po" else f".{ext_target}" - # 2. FETCH ALL FILES TO FIND MATCHING ID - files = client.source_files.with_fetch_all().list_files(project_id) - file_id = None + print(f"DEBUG: Searching for source file: {base_target}{search_ext}") - for f in files["data"]: - path_crowdin = f["data"]["path"].lower() - if path_crowdin.endswith(f"{base_target}{search_ext}"): - file_id = f["data"]["id"] - break + file_id = find_file_id(client, p_id, base_target, search_ext) if file_id is None: + print(f"WARNING: File '{base_target}{search_ext}' not found on Crowdin.") return 0.0 - # 3. FETCH PROGRESS FOR THE SPECIFIC FILE - # We use get_file_progress which is reliable for specific file IDs - progress = client.translation_status.get_file_progress(projectId=project_id, fileId=file_id) - - for item in progress["data"]: - if item["data"]["languageId"].lower() == lang_id.lower(): - # Return ratio (0.0 to 1.0) - return float(item["data"]["translationProgress"]) / 100 + # Pagination for translation status (Progress) + offset = 0 + limit = 100 + + while True: + resp = client.translation_status.get_file_progress( + projectId=p_id, + fileId=file_id, + limit=limit, + offset=offset + ) + + data = resp['data'] + for item in data: + lang_api = item['data']['languageId'] + + # Flexible matching (e.g., 'fr' will match 'fr' or 'fr-FR' from API) + # Also handles underscore to dash conversion for Crowdin compatibility + if lang_api.lower().startswith(lang_id.lower().replace('_', '-')): + progress = float(item['data']['translationProgress']) + return progress / 100 + + # Check pagination total + total = resp['pagination']['totalCount'] + if offset + limit >= total: + break + offset += limit - except Exception: - # Fallback to 0.0 in case of API or network error + print(f"DEBUG: Language '{lang_id}' not found in progress list for this file.") return 0.0 - return 0.0 - - -# ----------------------------- -# MAIN ENGINE -# ----------------------------- - + except Exception as e: + print(f"API ERROR: {e}") + return 0.0 def main(): - """ - Main entry point. - Expects two arguments: - """ if len(sys.argv) < 3: + print("Usage: python checkTranslation.py ") sys.exit(2) - file_name = sys.argv[1] + input_file = sys.argv[1] lang = sys.argv[2] - # All evaluations now go through the Crowdin API - score = get_score_from_api(lang, file_name) + score = get_score_from_api(input_file, lang) - # Output formatting for PowerShell capture + # Output formatted for capture by the PowerShell script (crowdinSync.ps1) print(f"translationRatio={score}") - if file_name.lower().endswith(".md"): + + if input_file.lower().endswith('.md'): print(f"mdScore={score}") else: print(f"poScore={score}") - # Exit with code 0 if score > 5%, otherwise 1 + # Exit with success (0) if there is at least some translated content sys.exit(0 if score > 0.05 else 1) - if __name__ == "__main__": main() diff --git a/.github/scripts/crowdinSync.ps1 b/.github/scripts/crowdinSync.ps1 index 0237ef1..a96ae3e 100644 --- a/.github/scripts/crowdinSync.ps1 +++ b/.github/scripts/crowdinSync.ps1 @@ -1,7 +1,7 @@ #!/usr/bin/env pwsh $ErrorActionPreference = 'Stop' -# Config git +# Git configuration for automated commits git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" @@ -11,133 +11,156 @@ if (-not $addonId) { exit 1 } -# Update xliff file +# --- STEP 1: PREPARATION AND SOURCE UPDATE --- + $xliffFile = "./$addonId.xliff" $mdFile = "./readme.md" + if (Test-Path $mdFile) { if (Test-Path $xliffFile) { $tempXliff = [System.IO.Path]::GetTempFileName() Copy-Item "$addonId.xliff" $tempXliff -Force - Write-Host "Copied $addonId.xliff to temporary file: $tempXliff" + Write-Host "DEBUG: Updating XLIFF source based on readme.md..." uv run .github/scripts/markdownTranslate.py updateXliff -m $mdFile -x $tempXliff -o $xliffFile - Write-Host "Updated $xliffFile based on $mdFile" } else { - Write-Host "XLIFF file not found, but readme.md exists. Creating an XLIFF template for translations." + Write-Host "DEBUG: XLIFF template not found. Creating new one from readme.md..." uv run .github/scripts/markdownTranslate.py generateXliff -m $mdFile -o $xliffFile } -} else { - Write-Host "readme.md not found. Skipping XLIFF generation." } -# Update pot file in Crowdin +# Update POT file (addon interface) uv run scons pot $potFile = "$addonId.pot" + +# --- STEP 2: UPLOAD SOURCES TO CROWDIN --- + if (Test-Path $potFile) { - Write-Host "Uploading updated POT to Crowdin..." + Write-Host "DEBUG: Uploading updated POT source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$potFile" -c addon -} else { - Write-Host "POT file not found, skipping POT update." } -# Update xliff file in Crowdin if (Test-Path $xliffFile) { - Write-Host "Uploading XLIFF to Crowdin..." + Write-Host "DEBUG: Uploading updated XLIFF source to Crowdin..." ./l10nUtil.exe uploadSourceFile "$xliffFile" -c addon git add "$xliffFile" git diff --staged --quiet if ($LASTEXITCODE -ne 0) { git commit -m "Update $xliffFile for $addonId" git push - } else { - Write-Host "No changes to $xliffFile, skipping commit." } -} else { - Write-Host "XLIFF file not found, skipping XLIFF upload." } -# Export translations -Write-Host "Exporting translations from Crowdin..." +# --- STEP 3: EXPORT AND PROCESS TRANSLATIONS --- + +Write-Host "DEBUG: Exporting translations from Crowdin..." ./l10nUtil.exe exportTranslations -o _addonL10n -c addon # Ensure base directories exist New-Item -ItemType Directory -Force -Path addon/locale | Out-Null New-Item -ItemType Directory -Force -Path addon/doc | Out-Null +# Load language mappings for Crowdin API calls $languageMappings = Get-Content -Raw ".github/scripts/languageMappings.json" | ConvertFrom-Json -foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { - $langCode = $dir.Name +foreach ($dir in Get-ChildItem -Path "_addonL10n/$addonId" -Directory) { + $langCode = $dir.Name + if ($langCode -eq "en") { continue } + + # Identify codes $crowdinLang = $languageMappings[$langCode] if (-not $crowdinLang) { $crowdinLang = $langCode } - $langShort = $langCode.Split('_')[0] - Write-Host "--- Processing: $addonId ($langCode) ---" + $langShort = $langCode.Split('-')[0].Split('_')[0] - # Temporary files from Crowdin + # Map to local NVDA directory + $localLangDir = uv run python .github/scripts/langCodes.py $langCode + + Write-Host "`n--- Processing Language: $langCode (Mapped to local: $localLangDir) ---" + + # Paths $remoteMd = Join-Path $dir.FullName "$addonId.md" $remoteXliff = Join-Path $dir.FullName "$addonId.xliff" $remotePo = Join-Path $dir.FullName "$addonId.po" - - # Local paths - $localMdDir = "addon/doc/$langCode" + $localMdDir = "addon/doc/$localLangDir" $localMd = "$localMdDir/readme.md" - $localPoPath = "addon/locale/$langCode/LC_MESSAGES/nvda.po" + $localPoPath = "addon/locale/$localLangDir/LC_MESSAGES/nvda.po" - # 1. PO PROCESSING + # --- 3.1 PO FILE PROCESSING --- + $poImported = $false if (Test-Path $remotePo) { + Write-Host "DEBUG: Checking Remote PO progress for $langShort..." uv run python .github/scripts/checkTranslation.py "$addonId.po" $langShort if ($LASTEXITCODE -eq 0) { + Write-Host "SUCCESS: Remote PO is valid. 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." } } - # 2. EVALUATION VIA API + 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 addon + } + + # --- 3.2 DOCUMENTATION PROCESSING (MD & XLIFF) --- $scoreMd = 0.0 $scoreXliff = 0.0 if (Test-Path $remoteMd) { + Write-Host "DEBUG: Evaluating Remote Markdown score..." $res = uv run python .github/scripts/checkTranslation.py "$addonId.md" $langShort $scoreMd = [double]($res | Select-String "mdScore=").ToString().Split("=")[1] + } else { + Write-Host "DEBUG: No remote Markdown file found for this language." } if (Test-Path $remoteXliff) { + Write-Host "DEBUG: Evaluating Remote XLIFF score..." $res = uv run python .github/scripts/checkTranslation.py "$addonId.xliff" $langShort $scoreXliff = [double]($res | Select-String "translationRatio=").ToString().Split("=")[1] + } else { + Write-Host "DEBUG: No remote XLIFF file found for this language." } - Write-Host "Scores -> MD: $scoreMd | XLIFF: $scoreXliff" + Write-Host "DEBUG: Comparison Scores -> MD: $scoreMd | XLIFF: $scoreXliff" - # 3. DECISION LOGIC $threshold = 0.5 - $imported = $false + $docImported = $false if ($scoreXliff -gt $threshold -or $scoreMd -gt $threshold) { - # Create doc directory if needed if (!(Test-Path $localMdDir)) { New-Item -ItemType Directory -Force -Path $localMdDir | Out-Null } if ($scoreXliff -ge $scoreMd) { - Write-Host "Action: Converting XLIFF to local MD" + Write-Host "SUCCESS: XLIFF is better or equal. Converting XLIFF to local MD ($localLangDir)..." ./l10nUtil.exe xliff2md $remoteXliff $localMd - $imported = $true + $docImported = $true } else { - Write-Host "Action: Importing Remote MD to local" + Write-Host "SUCCESS: Markdown is better. Importing Remote MD to local ($localLangDir)..." Move-Item $remoteMd $localMd -Force - $imported = $true + $docImported = $true } + } else { + Write-Host "WARNING: Both remote MD and XLIFF scores are below threshold ($threshold)." } - # 4. FALLBACK: Upload local if remote is poor - if (-not $imported -and (Test-Path $localMd)) { - Write-Host "Action: Remote quality too low. Uploading local MD to Crowdin..." + 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 addon } } +# --- 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" + git commit -m "Update translations for $addonId from Crowdin (Automatic Sync)" $branch = $env:downloadTranslationsBranch git push -f origin "HEAD:$branch" + Write-Host "SUCCESS: Translations committed and pushed." +} else { + Write-Host "DEBUG: No changes in translations to commit." } diff --git a/.github/scripts/langCodes.py b/.github/scripts/langCodes.py new file mode 100644 index 0000000..044e5b5 --- /dev/null +++ b/.github/scripts/langCodes.py @@ -0,0 +1,60 @@ +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 From ef0a614319e65de7d07290e44bc7c85e6436d1db Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 09:07:01 +0200 Subject: [PATCH 098/100] docs: update readme with translation workflow instructions - Add instructions for requesting developer access to the Crowdin project via the NVDA add-on mailing list. - Document required GitHub Secrets (crowdinAuthToken and CROWDIN_PROJECT_ID). - List necessary scripts and workflow files for the translation infrastructure. - Clarify the initial export and periodic download processes. --- readme.md | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index c8d33d2..7b777cb 100644 --- a/readme.md +++ b/readme.md @@ -177,13 +177,28 @@ If not, leave the dictionary empty. ### Translation workflow -You can add the documentation and interface messages of your add-on to be translated in Crowdin. - -You need a Crowdin account and an API token with permissions to push to a Crowdin project. -For example, you may want to use this [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons). - -Then, to export your add-on to Crowdin for the first time, run the `.github/workflows/exportAddonsToCrowdin.yml`, ensuring that the update option is set to false. -When you have updated messages or documentation, run the workflow setting update to true (which is the default option). +This template allows you to automate the synchronization of documentation and interface messages with Crowdin. + +#### 1. Crowdin Project Setup +You need a Crowdin account and an API token with permissions to manage a project. +If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): +* **Request Access:** Send a message to the [NVDA add-on development/discussion list](https://nvda-addons.groups.io/g/nvda-addons) requesting an invitation to join the project as a developer. +* **API Token:** Once invited, generate an API token in your Crowdin account settings. + +#### 2. GitHub Secrets +To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): +* `crowdinAuthToken`: Paste your Crowdin API token here. +* `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. + +#### 3. Infrastructure +Ensure that your repository includes the following files (provided in this template): +* **Workflows:** `.github/workflows/exportAddonsToCrowdin.yml` and `.github/workflows/downloadTranslations.yml`. +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, and `crowdinSync.ps1`. + +#### 4. Running the Workflow +* **Initial Export:** To export your add-on to Crowdin for the first time, run the `exportAddonsToCrowdin.yml` workflow, ensuring that the "update" option is set to **false**. +* **Updates:** When you have updated messages or documentation, run the same workflow with "update" set to **true** (default). +* **Download:** The `downloadTranslations.yml` workflow will periodically (or manually) fetch new translations, verify their quality using the scripts, and create a Pull Request with the updated `.po` and `readme.md` files. ### Additional tools From 92e1cd61d6136a8713bd962867265405b2299b9d Mon Sep 17 00:00:00 2001 From: Abdel792 Date: Tue, 28 Apr 2026 09:18:05 +0200 Subject: [PATCH 099/100] docs: fix translation mailing list address in readme.md --- readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/readme.md b/readme.md index 7b777cb..b767408 100644 --- a/readme.md +++ b/readme.md @@ -182,11 +182,11 @@ This template allows you to automate the synchronization of documentation and in #### 1. Crowdin Project Setup You need a Crowdin account and an API token with permissions to manage a project. If you wish to use the community project [Crowdin project to translate NVDA add-ons](https://crowdin.com/project/nvdaaddons): -* **Request Access:** Send a message to the [NVDA add-on development/discussion list](https://nvda-addons.groups.io/g/nvda-addons) requesting an invitation to join the project as a developer. +* **Request Access:** Send a message to the [NVDA translation mailing list](https://groups.io/g/nvda-translations) (**nvda-translations@groups.io**) requesting an invitation to join the project as a developer. * **API Token:** Once invited, generate an API token in your Crowdin account settings. #### 2. GitHub Secrets -To allow the workflows to communicate with Crowdin, you must add the following secret to your GitHub repository (`Settings > Secrets and variables > Actions`): +To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): * `crowdinAuthToken`: Paste your Crowdin API token here. * `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. From 6a884c02be554638947a32dd3796b6cc3a10296b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Noelia=20Ruiz=20Mart=C3=ADnez?= Date: Tue, 28 Apr 2026 20:15:06 +0200 Subject: [PATCH 100/100] Fix readme --- readme.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index b767408..3be0986 100644 --- a/readme.md +++ b/readme.md @@ -188,17 +188,15 @@ If you wish to use the community project [Crowdin project to translate NVDA add- #### 2. GitHub Secrets To allow the workflows to communicate with Crowdin, you must add the following secrets to your GitHub repository (`Settings > Secrets and variables > Actions`): * `crowdinAuthToken`: Paste your Crowdin API token here. -* `CROWDIN_PROJECT_ID`: The ID of your project on Crowdin. #### 3. Infrastructure Ensure that your repository includes the following files (provided in this template): -* **Workflows:** `.github/workflows/exportAddonsToCrowdin.yml` and `.github/workflows/downloadTranslations.yml`. -* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, and `crowdinSync.ps1`. +* **Workflows:** `.github/workflows/crowdinL10n.yml** +* **Scripts:** The `.github/scripts/` folder containing `checkTranslation.py`, `langCodes.py`, `languageMappings.json`, `setOutputs.py`, and `crowdinSync.ps1`. #### 4. Running the Workflow -* **Initial Export:** To export your add-on to Crowdin for the first time, run the `exportAddonsToCrowdin.yml` workflow, ensuring that the "update" option is set to **false**. -* **Updates:** When you have updated messages or documentation, run the same workflow with "update" set to **true** (default). -* **Download:** The `downloadTranslations.yml` workflow will periodically (or manually) fetch new translations, verify their quality using the scripts, and create a Pull Request with the updated `.po` and `readme.md` files. + +The translation workflow will be run weekly. Also, you can run the workflow manually from GitHub or using GitHub CLI. ### Additional tools