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 01/54] 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 02/54] 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 03/54] 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 04/54] 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 05/54] 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 06/54] 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 07/54] 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 08/54] 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 09/54] 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 10/54] 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 11/54] 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 12/54] 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 13/54] 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 14/54] 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 15/54] 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 16/54] 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 17/54] 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 18/54] 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 19/54] 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 20/54] 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 21/54] 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 22/54] 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 23/54] 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 24/54] 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 25/54] 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 26/54] 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 27/54] 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 28/54] 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 29/54] 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 30/54] 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 31/54] 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 32/54] 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 33/54] 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 34/54] 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 35/54] 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 36/54] 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 37/54] 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 38/54] 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 39/54] 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 40/54] 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 41/54] 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 42/54] 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 43/54] 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 44/54] 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 45/54] 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 46/54] 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 47/54] 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 48/54] 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 49/54] =?UTF-8?q?Don't=20create=20a=20PR=20since=20this=20?= =?UTF-8?q?n=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 50/54] 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 51/54] 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 52/54] 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 53/54] 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 54/54] 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" }, +]