OpenMiInk/third_party/krita-ci-utilities/microsoft-store/submit-to-microsoft-store.py
Hellings b8c8ff98e2 Hi
2026-10-16 09:05:56 -04:00

896 lines
23 KiB
Python

#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 KDE e.V.
# SPDX-FileContributor: Ingo Klöcker <dev@ingo-kloecker.de>
#
# SPDX-License-Identifier: BSD-2-Clause
# See README.md for usage information.
import json
import logging
import os
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.parse import urlparse
from zipfile import ZipFile
try:
import requests
from azure.storage.blob import BlobClient
from markdownify import markdownify
from microstore.MicrosoftStoreClient import APIError, AuthorizationError, MicrosoftStoreClient
except ImportError as e:
print("Error: %s" % e, file=sys.stderr)
print("Please install the requirements (see requirements.txt)!")
sys.exit(1)
class Error(Exception):
pass
logger = logging.getLogger(__name__)
# Maps AppStream language ID to Microsoft language ID
appstream_to_microsoft_language = {
"en": "en-us", # the apps.kde.org appdata is actually a bit mangled the real key would be C not 'en'
"sr": "sr-cyrl",
"sr-la": "sr-Latn", # preserve capital letter (by default we downcase)
"ca-va": "ca-es-valencia",
"az": "az-arab",
}
# Languages that are supported by Microsoft Store
# (https://docs.microsoft.com/en-us/windows/uwp/publish/supported-languages):
microsoft_languages = [
# Arabic
"ar",
"ar-sa",
"ar-ae",
"ar-bh",
"ar-dz",
"ar-eg",
"ar-iq",
"ar-jo",
"ar-kw",
"ar-lb",
"ar-ly",
"ar-ma",
"ar-om",
"ar-qa",
"ar-sy",
"ar-tn",
"ar-ye",
# Afrikaans
"af",
"af-za",
# Albanian
"sq",
"sq-al",
# Amharic
"am",
"am-et",
# Armenian
"hy",
"hy-am",
# Assamese
"as",
"as-in",
# Azerbaijani
"az-arab",
"az-arab-az",
"az-cyrl",
"az-cyrl-az",
"az-latn",
"az-latn-az",
# Basque (Basque)
"eu",
"eu-es",
# Belarusian
"be",
"be-by",
# Bangla
"bn",
"bn-bd",
"bn-in",
# Bosnian
"bs",
"bs-cyrl",
"bs-cyrl-ba",
"bs-latn",
"bs-latn-ba",
# Bulgarian
"bg",
"bg-bg",
# Catalan
"ca",
"ca-es",
"ca-es-valencia",
# Cherokee
"chr-cher",
"chr-cher-us",
"chr-latn",
# Chinese (Simplified)
"zh-Hans",
"zh-cn",
"zh-hans-cn",
"zh-sg",
"zh-hans-sg",
# Chinese (Traditional)
"zh-Hant",
"zh-hk",
"zh-mo",
"zh-tw",
"zh-hant-hk",
"zh-hant-mo",
"zh-hant-tw",
# Croatian
"hr",
"hr-hr",
"hr-ba",
# Czech
"cs",
"cs-cz",
# Danish
"da",
"da-dk",
# Dari
"prs",
"prs-af",
"prs-arab",
# Dutch
"nl",
"nl-nl",
"nl-be",
# English
"en",
"en-au",
"en-ca",
"en-gb",
"en-ie",
"en-in",
"en-nz",
"en-sg",
"en-us",
"en-za",
"en-bz",
"en-hk",
"en-id",
"en-jm",
"en-kz",
"en-mt",
"en-my",
"en-ph",
"en-pk",
"en-tt",
"en-vn",
"en-zw",
"en-053",
"en-021",
"en-029",
"en-011",
"en-018",
"en-014",
# Estonian
"et",
"et-ee",
# Filipino
"fil",
"fil-latn",
"fil-ph",
# Finnish
"fi",
"fi-fi",
# French
"fr",
"fr-be ",
"fr-ca ",
"fr-ch ",
"fr-fr ",
"fr-lu",
"fr-015",
"fr-cd",
"fr-ci",
"fr-cm",
"fr-ht",
"fr-ma",
"fr-mc",
"fr-ml",
"fr-re",
"frc-latn",
"frp-latn",
"fr-155",
"fr-029",
"fr-021",
"fr-011",
# Galician
"gl",
"gl-es",
# Georgian
"ka",
"ka-ge",
# German
"de",
"de-at",
"de-ch",
"de-de",
"de-lu",
"de-li",
# Greek
"el",
"el-gr",
# Gujarati
"gu",
"gu-in",
# Hausa
"ha",
"ha-latn",
"ha-latn-ng",
# Hebrew
"he",
"he-il",
# Hindi
"hi",
"hi-in",
# Hungarian
"hu",
"hu-hu",
# Icelandic
"is",
"is-is",
# Igbo
"ig-latn",
"ig-ng",
# Indonesian
"id",
"id-id",
# Inuktitut (Latin)
"iu-cans",
"iu-latn",
"iu-latn-ca",
# Irish
"ga",
"ga-ie",
# isiXhosa
"xh",
"xh-za",
# isiZulu
"zu",
"zu-za",
# Italian
"it",
"it-it",
"it-ch",
# Japanese
"ja ",
"ja-jp",
# Kannada
"kn",
"kn-in",
# Kazakh
"kk",
"kk-kz",
# Khmer
"km",
"km-kh",
# K'iche',
"quc-latn",
"qut-gt",
"qut-latn",
# Kinyarwanda
"rw",
"rw-rw",
# KiSwahili
"sw",
"sw-ke",
# Konkani
"kok",
"kok-in",
# Korean
"ko",
"ko-kr",
# Kurdish
"ku-arab",
"ku-arab-iq",
# Kyrgyz
"ky-kg",
"ky-cyrl",
# Lao
"lo",
"lo-la",
# Latvian
"lv",
"lv-lv",
# Lithuanian
"lt",
"lt-lt",
# Luxembourgish
"lb",
"lb-lu",
# Macedonian
"mk",
"mk-mk",
# Malay
"ms",
"ms-bn",
"ms-my",
# Malayalam
"ml",
"ml-in",
# Maltese
"mt",
"mt-mt",
# Maori
"mi",
"mi-latn",
"mi-nz",
# Marathi
"mr",
"mr-in",
# Mongolian (Cyrillic)
"mn-cyrl",
"mn-mong",
"mn-mn",
"mn-phag",
# Nepali
"ne",
"ne-np",
# Norwegian
"nb",
"nb-no",
"nn",
"nn-no",
"no",
"no-no",
# Odia
"or",
"or-in",
# Persian
"fa",
"fa-ir",
# Polish
"pl",
"pl-pl",
# Portuguese (Brazil)
"pt-br",
# Portuguese (Portugal)
"pt",
"pt-pt",
# Punjabi
"pa",
"pa-arab",
"pa-arab-pk",
"pa-deva",
"pa-in",
# Quechua
"quz",
"quz-bo",
"quz-ec",
"quz-pe",
# Romanian
"ro",
"ro-ro",
# Russian
"ru ",
"ru-ru",
# Scottish Gaelic
"gd-gb",
"gd-latn",
# Serbian (Latin)
"sr-Latn",
"sr-latn-cs",
"sr",
"sr-latn-ba",
"sr-latn-me",
"sr-latn-rs",
# Serbian (Cyrillic)
"sr-cyrl",
"sr-cyrl-ba",
"sr-cyrl-cs",
"sr-cyrl-me",
"sr-cyrl-rs",
# Sesotho sa Leboa
"nso",
"nso-za",
# Setswana
"tn",
"tn-bw",
"tn-za",
# Sindhi
"sd-arab",
"sd-arab-pk",
"sd-deva",
# Sinhala
"si",
"si-lk",
# Slovak
"sk",
"sk-sk",
# Slovenian
"sl",
"sl-si",
# Spanish
"es",
"es-cl",
"es-co",
"es-es",
"es-mx",
"es-ar",
"es-bo",
"es-cr",
"es-do",
"es-ec",
"es-gt",
"es-hn",
"es-ni",
"es-pa",
"es-pe",
"es-pr",
"es-py",
"es-sv",
"es-us",
"es-uy",
"es-ve",
"es-019",
"es-419",
# Swedish
"sv",
"sv-se",
"sv-fi",
# Tajik (Cyrillic)
"tg-arab",
"tg-cyrl",
"tg-cyrl-tj",
"tg-latn",
# Tamil
"ta",
"ta-in",
# Tatar
"tt-arab",
"tt-cyrl",
"tt-latn",
"tt-ru",
# Telugu
"te",
"te-in",
# Thai
"th",
"th-th",
# Tigrinya
"ti",
"ti-et",
# Turkish
"tr",
"tr-tr",
# Turkmen
"tk-cyrl",
"tk-latn",
"tk-tm",
"tk-latn-tr",
"tk-cyrl-tr",
# Ukrainian
"uk",
"uk-ua",
# Urdu
"ur",
"ur-pk",
# Uyghur
"ug-arab",
"ug-cn",
"ug-cyrl",
"ug-latn",
# Uzbek (Latin)
"uz",
"uz-cyrl",
"uz-latn",
"uz-latn-uz",
# Vietnamese
"vi",
"vi-vn",
# Welsh
"cy",
"cy-gb",
# Wolof
"wo",
"wo-sn",
# Yoruba
"yo-latn",
"yo-ng",
]
# Languages supported by KDE that are not supported by Microsoft:
# * ast (Asturian), ia (Interlingua), ie (Interlingue) are not supported by Microsoft
# * some SR variants are not a thing outside the KDE bubble
# * x-test
def parseCommandLine():
import argparse
parser = argparse.ArgumentParser(description="Client for submitting KDE apps to the Microsoft Store")
# debug options
parser.add_argument("-v", "--verbose", action="count", default=0, help="increase the verbosity")
parser.add_argument(
"--skip-commit", dest="skipCommit", action="store_true", help="submit the changes, but skip the final commit"
)
parser.add_argument(
"--dry-run", dest="dryRun", action="store_true", help="perform a trial run without submitting any changes"
)
parser.add_argument(
"--debug-authorization",
type=int,
default=0,
dest="debugAuth",
metavar="LEVEL",
help="debug level for authorization (1 or 2)",
)
parser.add_argument(
"--debug-api-calls",
type=int,
default=0,
dest="debugApi",
metavar="LEVEL",
help="debug level for API calls (1 or 2)",
)
parser.add_argument(
"--tenant-id",
dest="tenantId",
help="the tenant ID; required; can also be set via MICROSTORE_TENANT_ID environment variable",
)
parser.add_argument(
"--client-id",
dest="clientId",
help="the client ID; required; can also be set via MICROSTORE_CLIENT_ID environment variable",
)
parser.add_argument(
"--store-id",
dest="storeId",
help="the Microsoft Store ID of the app; if not given, it is retrieved from the AppStream data",
)
parser.add_argument(
"--keep",
default="",
dest="keep",
help="comma-separated list of attributes to keep when updating the submission data from the AppStream data",
)
parser.add_argument("appstreamId", help="the AppStream ID of the application")
parser.add_argument("appxuploadFile", help="the .appxupload file to submit")
options = parser.parse_args()
options.keep = [s.lower() for s in options.keep.split(",") if s]
return options
def setUpLogging(options):
logging.basicConfig(format="%(asctime)s %(name)s %(message)s", level=logging.WARNING)
logger.setLevel(max(logging.DEBUG, logging.WARNING - 10 * options.verbose))
microstoreLogger = logging.getLogger("microstore")
microstoreLogger.setLevel(max(logging.DEBUG, logging.WARNING - 10 * options.verbose))
authLogger = logging.getLogger("microstore.auth")
authLogger.setLevel(max(logging.DEBUG, logging.WARNING - 10 * options.debugAuth))
apiLogger = logging.getLogger("microstore.api")
apiLogger.setLevel(max(logging.DEBUG, logging.WARNING - 10 * options.debugApi))
def getAppStreamData(appstreamId):
r = requests.get(f"https://apps.kde.org/appdata/{appstreamId}.json")
r.raise_for_status()
return r.json()
def createListing(title):
return {
"baseListing": {
"copyrightAndTrademarkInfo": "",
"keywords": [],
"licenseTerms": "",
"privacyPolicy": "",
"supportContact": "",
"websiteUrl": "",
"description": "",
"features": [],
"releaseNotes": "",
"images": [],
"recommendedHardware": [],
"minimumHardware": [],
"title": title,
"shortDescription": "",
"shortTitle": "",
"sortTitle": "",
"voiceTitle": "",
"devStudio": "",
}
}
def downloadFile(url, downloadPath):
localPath = downloadPath / Path(urlparse(url).path).name
if localPath.exists():
logger.debug(f"File {downloadPath} already exists. Skipping download.")
return localPath
logger.debug(f"Downloading {url}")
r = requests.get(url)
if not r.ok:
logger.warning(f"Failed to download {url}")
return
logger.debug(f"Writing file to {localPath}")
os.makedirs(downloadPath, exist_ok=True)
with open(localPath, "wb") as f:
f.write(r.content)
return localPath
def reformatDescription(description):
description = description.strip()
if description.startswith("<"):
# convert any linebreaks in the HTML code to spaces (linebreaks are
# meaningless in HTML, but markdownify does not touch them)
description = description.replace("\n", " ")
# convert HTML description to Markdown (Microsoft Store only supports
# plain text, but some Markdown looks nicer)
description = markdownify(description, heading_style="UNDERLINED").strip()
# remove leading (and trailing) whitespace from all lines
description = "\n".join(line.strip() for line in description.split("\n"))
return description
def updateBaseListingForLanguage(lang, storeLang, baseListing, defaultBaseListing, appstreamData, downloadPath, keep):
# update description for listing
storeDescription = baseListing.get("description", "")
if "description" not in keep or not storeDescription:
description = appstreamData.get("Description", {}).get(lang, "")
if not description:
logger.debug(f"No description for {lang}. Skipping update of listing for language.")
return
baseListing["description"] = reformatDescription(description)
# copyrightAndTrademarkInfo - AppStream does not provide this
# update keywords for listing
if "keywords" not in keep or not baseListing["keywords"]:
baseListing["keywords"] = appstreamData.get("Keywords", {}).get(lang, [])
# update licenseTerms for listing
if "licenseterms" not in keep or not baseListing["licenseTerms"]:
baseListing["licenseTerms"] = appstreamData.get("ProjectLicense", "")
# privacyPolicy - This value is obsolete. The privacy policy URL must be set in Partner Center.
# supportContact - This value is obsolete. The support contact URL or email address must be set in Partner Center.
# websiteUrl - This value is obsolete. The URL of the web page for your app must be set in Partner Center.
# features - AppStream does not provide a list of features (https://github.com/ximion/appstream/issues/388)
# update releaseNotes for listing
if "releasenotes" not in keep or not baseListing["releaseNotes"]:
releases = appstreamData.get("Releases")
if releases: # not None and not empty
releaseNotes = releases[0].get("description", {}).get(lang, "") or defaultBaseListing["releaseNotes"]
baseListing["releaseNotes"] = releaseNotes
else:
baseListing["releaseNotes"] = ""
# update images for listing
if "images" not in keep or not baseListing["images"]:
newScreenshots = []
for screenshot in appstreamData.get("Screenshots", []):
if not screenshot.get("default", False):
continue
url = screenshot.get("source-image", {}).get("url")
imagePath = downloadFile(url, downloadPath / "images")
if imagePath is not None:
description = screenshot.get("caption", {}).get(lang, "")
# assume screenshot for mobile if image filename contains 'mobile'
imageType = "MobileScreenshot" if "mobile" in imagePath.name else "Screenshot"
newScreenshots.append(
{
"fileName": "images/" + imagePath.name,
"fileStatus": "PendingUpload",
"description": description,
"imageType": imageType,
}
)
if newScreenshots:
# replace old screenshots with new ones
baseListing["images"] = [
image for image in baseListing["images"] if not image["imageType"].endswith("Screenshot")
]
baseListing["images"].extend(newScreenshots)
# recommendedHardware, minimumHardware - AppStream does not provide these
# title - title must be a reserved name; keep what's there
# update shortDescription for listing (only used for games)
if "shortdescription" not in keep or not baseListing["shortDescription"]:
baseListing["shortDescription"] = appstreamData.get("Summary", {}).get(lang, "")
# shortTitle, sortTitle, voiceTitle - AppStream does not provide these
# update devStudio for listing
if "devstudio" not in keep or not baseListing["devStudio"]:
baseListing["devStudio"] = (
appstreamData.get("DeveloperName", {}).get(lang, "") or defaultBaseListing["devStudio"]
)
logger.debug(f"Listing for {storeLang}: %s", json.dumps(baseListing))
return baseListing
def updateSubmissionWithAppStreamData(submissionData, appstreamData, downloadPath, keep, title):
listings = submissionData["listings"]
defaultBaseListing = listings["en-us"]["baseListing"]
if keep:
logger.debug("Keeping the following values of the existing listings: %s", ", ".join(keep))
# first update the (base) listing for en/en-us (the default listing)
updateBaseListingForLanguage(
"en", "en-us", defaultBaseListing, defaultBaseListing, appstreamData, downloadPath, keep
)
for lang in appstreamData["Name"]:
if lang == "en":
continue
storeLang = appstream_to_microsoft_language.get(lang, lang)
if storeLang not in microsoft_languages:
logger.debug(f"Skipping language {lang} that is not supported by the Microsoft Store")
continue
listing = listings.get(storeLang)
if listing is None:
listing = createListing(title)
baseListing = listing["baseListing"]
updatedBaseListing = updateBaseListingForLanguage(
lang, storeLang, baseListing, defaultBaseListing, appstreamData, downloadPath, keep
)
# we keep existing listings even if updateBaseListingForLanguage doesn't like them
if updatedBaseListing is not None:
listings[storeLang] = listing
return submissionData
def uploadPackagesFiles(zipFilePath, fileUploadUrl):
logger.debug(f"Uploading {zipFilePath} to {fileUploadUrl}")
blobClient = BlobClient.from_blob_url(fileUploadUrl)
with open(zipFilePath, "rb") as data:
result = blobClient.upload_blob(data, blob_type="BlockBlob")
logger.debug("Upload result: %s", result)
def submitApp(client, *, appstreamId, appxuploadFile, storeId, keep, dryRun, skipCommit, **kwargs):
if not os.path.isfile(appxuploadFile):
raise Error(f"File not found: {appxuploadFile}")
if dryRun:
logger.info("Running in dry-run mode")
# fetch AppStream metadata of the app
logger.info(f"Fetching AppStream metadata for {appstreamId}")
appstreamData = getAppStreamData(appstreamId)
if not storeId:
# get the Store ID of the app from the AppStream data
storeUrl = appstreamData.get("Custom", {}).get("KDE::windows_store")
if storeUrl is None:
raise Error("No KDE::windows_store entry found in AppStream data.")
storeId = os.path.basename(urlparse(storeUrl).path)
logger.debug(f"Using Store ID {storeId}")
# get app data
logger.info("Retrieving information about the app from the Microsoft Store")
appData = client.getAppData(storeId)
lastSubmissionId = appData.get("lastPublishedApplicationSubmission", {}).get("id")
if not lastSubmissionId:
raise Error("The first submission needs to be done manually in the Microsoft Partner Center.")
# delete existing pending submission
pendingSubmissionId = appData.get("pendingApplicationSubmission", {}).get("id")
if pendingSubmissionId: # not None and not empty
logger.info("Deleting existing pending submission")
if not dryRun:
client.deleteSubmission(storeId, pendingSubmissionId)
# create a new submission (as copy of the last submission)
logger.info("Creating new submission")
if not dryRun:
submissionData = client.createSubmission(storeId)
else:
submissionData = client.getSubmission(storeId, lastSubmissionId)
submissionData["id"] = int(submissionData["id"]) + 1
submissionData["fileUploadUrl"] = "https://localhost"
submissionId = submissionData["id"]
logger.debug(f"submission ID: {submissionId}")
# remove some entries from the submission data
fileUploadUrl = submissionData["fileUploadUrl"]
logger.debug(f"fileUploadUrl: {fileUploadUrl}")
del submissionData["fileUploadUrl"] # remove fileUploadUrl
# remove deprecated "pricing.sales" entry
if "pricing" in submissionData and "sales" in submissionData["pricing"]:
del submissionData["pricing"]["sales"]
with TemporaryDirectory() as tmpdirname:
tempPath = Path(tmpdirname)
# set data for new submission
submissionData = updateSubmissionWithAppStreamData(
submissionData, appstreamData, tempPath, keep, title=appData["primaryName"]
)
# add information about the app package (if it has not yet been uploaded)
appPackageName = os.path.basename(appxuploadFile)
if any(
p["fileName"] == appPackageName and p["fileStatus"] == "Uploaded"
for p in submissionData["applicationPackages"]
):
# the app package has already been uploaded
logger.info(f"{appPackageName} has already been uploaded")
appPackageName = None
else:
logger.info(f"Adding {appPackageName} to list of application packages")
submissionData["applicationPackages"].append(
{
"fileName": appPackageName, # relative to root of uploaded ZIP file
"fileStatus": "PendingUpload",
}
)
# update the submission
logger.info("Updating submission")
if not dryRun:
client.updateSubmission(storeId, submissionId, submissionData)
else:
print(f"Updated submission data:\n{json.dumps(submissionData, indent=4)}")
# upload images and packages in a zip file
logger.info("Packaging files to upload")
zipFilePath = tempPath / "blob.zip"
# we use the default ZIP_STORED compression because the files should already be fairly compressed
with ZipFile(zipFilePath, "w") as zipFile:
for imagePath in (tempPath / "images").iterdir():
logger.debug(f"Adding {imagePath} to {zipFilePath}")
zipFile.write(imagePath, arcname="images/" + imagePath.name)
if appPackageName:
logger.debug(f"Adding {appPackageName} to {zipFilePath}")
zipFile.write(appxuploadFile, arcname=appPackageName)
logger.info("Uploading packaged files")
if not dryRun:
uploadPackagesFiles(zipFilePath, fileUploadUrl)
# commit the submission
if not skipCommit:
logger.info("Committing submission")
if not dryRun:
client.commitSubmission(storeId, submissionId, waitUntilCommitIsCompleted=True)
else:
logger.info("Skipped committing the submission")
def main():
options = parseCommandLine()
setUpLogging(options)
client = MicrosoftStoreClient(options.tenantId, options.clientId)
try:
submitApp(client, **vars(options))
return 0
except AuthorizationError as e:
print(f"Error: Authentication failed: {e}. Use `--debug-authorization 2` for details.", file=sys.stderr)
except APIError as e:
print(f"Error: API request failed: {e}. Use `--debug-api-calls 2` for details.", file=sys.stderr)
return 1
if __name__ == "__main__":
try:
sys.exit(main())
except Error as e:
print("Error: %s" % e, file=sys.stderr)
sys.exit(1)