Advance Wayland and KDE package bring-up
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
+528
@@ -0,0 +1,528 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# SPDX-FileCopyrightText: 2018-2020 Aleix Pol Gonzalez <aleixpol@kde.org>
|
||||
# SPDX-FileCopyrightText: 2019-2020 Ben Cooksley <bcooksley@kde.org>
|
||||
# SPDX-FileCopyrightText: 2020 Volker Krause <vkrause@kde.org>
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
#
|
||||
# Generates fastlane metadata for Android apps from appstream files.
|
||||
#
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import xdg.DesktopEntry
|
||||
import xml.etree.ElementTree as ET
|
||||
import yaml
|
||||
import zipfile
|
||||
|
||||
# Constants used in this script
|
||||
# map KDE's translated language codes to those expected by Android
|
||||
# see https://f-droid.org/en/docs/Translation_and_Localization/
|
||||
# F-Droid is more tolerant than the Play Store here, the latter rejects anything not exactly matching its known codes
|
||||
# Android does do the expected fallbacks, so the seemingly "too specific" mappings here are still working as expected
|
||||
# see https://developer.android.com/guide/topics/resources/multilingual-support#resource-resolution-examples
|
||||
# The following is the list of languages/translations that can be selected on the page "Main store listing" under
|
||||
# "Store presence" of Google Play Console (as of 2024-03-15).
|
||||
# Afrikaans – af
|
||||
# Albanian – sq
|
||||
# Amharic – am
|
||||
# Arabic – ar
|
||||
# Armenian – hy-AM
|
||||
# Azerbaijani – az-AZ
|
||||
# Bangla – bn-BD
|
||||
# Basque – eu-ES
|
||||
# Belarusian – be
|
||||
# Bulgarian – bg
|
||||
# Burmese – my-MM
|
||||
# Catalan – ca
|
||||
# Chinese (Hong Kong) – zh-HK
|
||||
# Chinese (Simplified) – zh-CN
|
||||
# Chinese (Traditional) – zh-TW
|
||||
# Croatian – hr
|
||||
# Czech – cs-CZ
|
||||
# Danish – da-DK
|
||||
# Dutch – nl-NL
|
||||
# English (Australia) – en-AU
|
||||
# English (Canada) – en-CA
|
||||
# English (United Kingdom) – en-GB
|
||||
# English – en-IN
|
||||
# English – en-SG
|
||||
# English – en-ZA
|
||||
# Estonian – et
|
||||
# Filipino – fil
|
||||
# Finnish – fi-FI
|
||||
# French (Canada) – fr-CA
|
||||
# French (France) – fr-FR
|
||||
# Galician – gl-ES
|
||||
# Georgian – ka-GE
|
||||
# German – de-DE
|
||||
# Greek – el-GR
|
||||
# Gujarati – gu
|
||||
# Hebrew – iw-IL
|
||||
# Hindi – hi-IN
|
||||
# Hungarian – hu-HU
|
||||
# Icelandic – is-IS
|
||||
# Indonesian – id
|
||||
# Italian – it-IT
|
||||
# Japanese – ja-JP
|
||||
# Kannada – kn-IN
|
||||
# Kazakh – kk
|
||||
# Khmer – km-KH
|
||||
# Korean – ko-KR
|
||||
# Kyrgyz – ky-KG
|
||||
# Lao – lo-LA
|
||||
# Latvian – lv
|
||||
# Lithuanian – lt
|
||||
# Macedonian – mk-MK
|
||||
# Malay (Malaysia) – ms-MY
|
||||
# Malay – ms
|
||||
# Malayalam – ml-IN
|
||||
# Marathi – mr-IN
|
||||
# Mongolian – mn-MN
|
||||
# Nepali – ne-NP
|
||||
# Norwegian – no-NO
|
||||
# Persian – fa
|
||||
# Persian – fa-AE
|
||||
# Persian – fa-AF
|
||||
# Persian – fa-IR
|
||||
# Polish – pl-PL
|
||||
# Portuguese (Brazil) – pt-BR
|
||||
# Portuguese (Portugal) – pt-PT
|
||||
# Punjabi – pa
|
||||
# Romanian – ro
|
||||
# Romansh – rm
|
||||
# Russian – ru-RU
|
||||
# Serbian – sr
|
||||
# Sinhala – si-LK
|
||||
# Slovak – sk
|
||||
# Slovenian – sl
|
||||
# Spanish (Latin America) – es-419
|
||||
# Spanish (Spain) – es-ES
|
||||
# Spanish (United States) – es-US
|
||||
# Swahili – sw
|
||||
# Swedish – sv-SE
|
||||
# Tamil – ta-IN
|
||||
# Telugu – te-IN
|
||||
# Thai – th
|
||||
# Turkish – tr-TR
|
||||
# Ukrainian – uk
|
||||
# Urdu – ur
|
||||
# Vietnamese – vi
|
||||
# Zulu – zu
|
||||
languageMap = {
|
||||
None: "en-US",
|
||||
"ast": None, # not supported by Google Play for meta data
|
||||
"ca-valencia": None, # not supported by Android
|
||||
"cs": "cs-CZ",
|
||||
"da": "da-DK",
|
||||
"de": "de-DE",
|
||||
"el": "el-GR",
|
||||
"eo": None, # neither supported by Android nor by Google Play for meta data
|
||||
"es": "es-ES",
|
||||
"eu": "eu-ES",
|
||||
"fi": "fi-FI",
|
||||
"fr": "fr-FR",
|
||||
"gl": "gl-ES",
|
||||
"ia": None, # not supported by Google Play for meta data
|
||||
"it": "it-IT",
|
||||
"ka": "ka-GE",
|
||||
"ko": "ko-KR",
|
||||
"nl": "nl-NL",
|
||||
"nn": "no-NO", # Google Play only supports no-NO (no = macrolanguage for nb/Bokmal and nn/Nynorsk)
|
||||
"pl": "pl-PL",
|
||||
"pt": "pt-PT",
|
||||
"ru": "ru-RU",
|
||||
"sr": "sr-Cyrl-RS",
|
||||
"sr@latin": "sr-Latn-RS",
|
||||
"sv": "sv-SE",
|
||||
"tr": "tr-TR",
|
||||
'x-test': None
|
||||
}
|
||||
|
||||
# The subset of supported rich text tags in F-Droid and Google Play
|
||||
# - see https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/ for F-Droid
|
||||
# - Google Play doesn't support lists
|
||||
supportedRichTextTags = { 'b', 'u', 'i' }
|
||||
|
||||
# List all translated languages present in an Appstream XML file
|
||||
def listAllLanguages(root, langs):
|
||||
for elem in root:
|
||||
lang = elem.get('{http://www.w3.org/XML/1998/namespace}lang')
|
||||
if not lang in langs:
|
||||
langs.add(lang)
|
||||
listAllLanguages(elem, langs)
|
||||
|
||||
# Apply language fallback to a map of translations
|
||||
def applyLanguageFallback(data, allLanguages):
|
||||
for l in allLanguages:
|
||||
if not l in data or not data[l] or len(data[l]) == 0:
|
||||
data[l] = data[None]
|
||||
|
||||
# Android appdata.xml textual item parser
|
||||
# This function handles reading standard text entries within an Android appdata.xml file
|
||||
# In particular, it handles splitting out the various translations, and converts some HTML to something which F-Droid can make use of
|
||||
# We have to handle incomplete translations both on top-level and intermediate tags,
|
||||
# and fall back to the English default text where necessary.
|
||||
def readText(elem, found, allLanguages):
|
||||
# Determine the language this entry is in
|
||||
lang = elem.get('{http://www.w3.org/XML/1998/namespace}lang')
|
||||
|
||||
# Do we have any text for this language yet?
|
||||
# If not, get everything setup
|
||||
for l in allLanguages:
|
||||
if not l in found:
|
||||
found[l] = ""
|
||||
|
||||
# If there is text available, we'll want to extract it
|
||||
# Additionally, if this element has any children, make sure we read those as well
|
||||
if elem.tag in supportedRichTextTags:
|
||||
if (elem.text and elem.text.strip()) or lang:
|
||||
found[lang] += '<' + elem.tag + '>'
|
||||
else:
|
||||
for l in allLanguages:
|
||||
found[l] += '<' + elem.tag + '>'
|
||||
elif elem.tag == 'li':
|
||||
found[lang] += '· '
|
||||
|
||||
if elem.text and elem.text.strip():
|
||||
found[lang] += elem.text
|
||||
|
||||
subOutput = {}
|
||||
for child in elem:
|
||||
if not child.get('{http://www.w3.org/XML/1998/namespace}lang') and len(subOutput) > 0:
|
||||
applyLanguageFallback(subOutput, allLanguages)
|
||||
for l in allLanguages:
|
||||
found[l] += subOutput[l]
|
||||
subOutput = {}
|
||||
readText(child, subOutput, allLanguages)
|
||||
if len(subOutput) > 0:
|
||||
applyLanguageFallback(subOutput, allLanguages)
|
||||
for l in allLanguages:
|
||||
found[l] += subOutput[l]
|
||||
|
||||
if elem.tag in supportedRichTextTags:
|
||||
if (elem.text and elem.text.strip()) or lang:
|
||||
found[lang] += '</' + elem.tag + '>'
|
||||
else:
|
||||
for l in allLanguages:
|
||||
found[l] += '</' + elem.tag + '>'
|
||||
|
||||
# Finally, if this element is a HTML Paragraph (p) or HTML List Item (li) make sure we add a new line for presentation purposes
|
||||
if elem.tag == 'li' or elem.tag == 'p':
|
||||
found[lang] += "\n"
|
||||
|
||||
|
||||
# Create the various Fastlane format files per the information we've previously extracted
|
||||
# These files are laid out following the Fastlane specification (links below)
|
||||
# https://github.com/fastlane/fastlane/blob/2.28.7/supply/README.md#images-and-screenshots
|
||||
# https://docs.fastlane.tools/actions/supply/
|
||||
def createFastlaneFile( applicationName, filenameToPopulate, fileContent ):
|
||||
# Go through each language and content pair we've been given
|
||||
for lang, text in fileContent.items():
|
||||
# First, do we need to amend the language id, to turn the Android language ID into something more F-Droid/Fastlane friendly?
|
||||
languageCode = languageMap.get(lang, lang)
|
||||
if not languageCode:
|
||||
continue
|
||||
|
||||
# Next we need to determine the path to the directory we're going to be writing the data into
|
||||
repositoryBasePath = arguments.output
|
||||
path = os.path.join( repositoryBasePath, 'metadata', applicationName, languageCode )
|
||||
|
||||
# Make sure the directory exists
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
# Now write out file contents!
|
||||
with open(path + '/' + filenameToPopulate, 'w') as f:
|
||||
f.write(text.strip()) # trim whitespaces, to avoid spurious differences after a Google Play roundtrip
|
||||
|
||||
# Create the summary appname.yml file used by F-Droid to summarise this particular entry in the repository
|
||||
# see https://f-droid.org/en/docs/Build_Metadata_Reference/
|
||||
def createYml(appname, data):
|
||||
# Prepare to retrieve the existing information
|
||||
info = {}
|
||||
|
||||
# Determine the path to the appname.yml file
|
||||
repositoryBasePath = arguments.output
|
||||
path = os.path.join( repositoryBasePath, 'metadata', appname + '.yml' )
|
||||
|
||||
# Update the categories first
|
||||
# Now is also a good time to add 'KDE' to the list of categories as well
|
||||
if 'categories' in data:
|
||||
info['Categories'] = data['categories'][None] + ['KDE']
|
||||
else:
|
||||
info['Categories'] = ['KDE']
|
||||
|
||||
# Update the general summary as well
|
||||
info['Summary'] = data['summary'][None]
|
||||
|
||||
# Check to see if we have a Homepage...
|
||||
if 'url-homepage' in data:
|
||||
info['WebSite'] = data['url-homepage'][None]
|
||||
|
||||
# What about a bug tracker?
|
||||
if 'url-bugtracker' in data:
|
||||
info['IssueTracker'] = data['url-bugtracker'][None]
|
||||
|
||||
if 'project_license' in data:
|
||||
info["License"] = data['project_license'][None]
|
||||
|
||||
if 'source-repo' in data:
|
||||
info['SourceCode'] = data['source-repo']
|
||||
|
||||
if 'url-donation' in data:
|
||||
info['Donate'] = data['url-donation'][None]
|
||||
else:
|
||||
info['Donate'] = 'https://kde.org/community/donations/'
|
||||
|
||||
# static data
|
||||
info['Translation'] = 'https://l10n.kde.org/'
|
||||
|
||||
# Finally, with our updates completed, we can save the updated appname.yml file back to disk
|
||||
with open(path, 'w') as output:
|
||||
yaml.dump(info, output, default_flow_style=False)
|
||||
|
||||
# Integrates locally existing image assets into the metadata
|
||||
def processLocalImages(applicationName, data):
|
||||
if not os.path.exists(os.path.join(arguments.source, 'fastlane')):
|
||||
return
|
||||
|
||||
outPath = os.path.abspath(arguments.output);
|
||||
oldcwd = os.getcwd()
|
||||
os.chdir(os.path.join(arguments.source, 'fastlane'))
|
||||
|
||||
imageFiles = glob.glob('metadata/**/*.png', recursive=True)
|
||||
imageFiles.extend(glob.glob('metadata/**/*.jpg', recursive=True))
|
||||
for image in imageFiles:
|
||||
# noramlize single- vs multi-app layouts
|
||||
imageDestName = image.replace('metadata/android', 'metadata/' + applicationName)
|
||||
|
||||
# copy image
|
||||
os.makedirs(os.path.dirname(os.path.join(outPath, imageDestName)), exist_ok=True)
|
||||
shutil.copy(image, os.path.join(outPath, imageDestName))
|
||||
|
||||
# if the source already contains screenshots, those override whatever we found in the appstream file
|
||||
if 'phoneScreenshots' in image:
|
||||
data['screenshots'] = {}
|
||||
|
||||
os.chdir(oldcwd)
|
||||
|
||||
# Attempt to find the application icon if we haven't gotten that explicitly from processLocalImages
|
||||
def findIcon(applicationName, iconBaseName):
|
||||
iconPath = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images', 'icon.png')
|
||||
if os.path.exists(iconPath):
|
||||
return
|
||||
|
||||
oldcwd = os.getcwd()
|
||||
os.chdir(arguments.source)
|
||||
|
||||
iconFiles = glob.glob(f"**/{iconBaseName}-playstore.png", recursive=True)
|
||||
for icon in iconFiles:
|
||||
os.makedirs(os.path.dirname(iconPath), exist_ok=True)
|
||||
shutil.copy(icon, iconPath)
|
||||
break
|
||||
|
||||
os.chdir(oldcwd)
|
||||
|
||||
# Download screenshots referenced in the appstream data
|
||||
# see https://f-droid.org/en/docs/All_About_Descriptions_Graphics_and_Screenshots/
|
||||
def downloadScreenshots(applicationName, data):
|
||||
if not 'screenshots' in data:
|
||||
return
|
||||
|
||||
path = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images', 'phoneScreenshots')
|
||||
os.makedirs(path, exist_ok=True)
|
||||
|
||||
i = 1 # number screenshots starting at 1 rather than 0 to match what the fastlane tool does
|
||||
for screenshot in data['screenshots']:
|
||||
fileName = str(i) + '-' + screenshot[screenshot.rindex('/') + 1:]
|
||||
r = requests.get(screenshot)
|
||||
if r.status_code < 400:
|
||||
with open(os.path.join(path, fileName), 'wb') as f:
|
||||
f.write(r.content)
|
||||
i += 1
|
||||
|
||||
# Put all metadata for the given application name into an archive
|
||||
# We need this to easily transfer the entire metadata to the signing machine for integration
|
||||
# into the F-Droid nightly repository
|
||||
def createMetadataArchive(applicationName):
|
||||
srcPath = os.path.join(arguments.output, 'metadata')
|
||||
zipFileName = os.path.join(srcPath, 'fastlane-' + applicationName + '.zip')
|
||||
if os.path.exists(zipFileName):
|
||||
os.unlink(zipFileName)
|
||||
archive = zipfile.ZipFile(zipFileName, 'w')
|
||||
archive.write(os.path.join(srcPath, applicationName + '.yml'), applicationName + '.yml')
|
||||
|
||||
oldcwd = os.getcwd()
|
||||
os.chdir(srcPath)
|
||||
for file in glob.iglob(applicationName + '/**', recursive=True):
|
||||
archive.write(file, file)
|
||||
os.chdir(oldcwd)
|
||||
|
||||
# Generate metadata for the given appstream and desktop files
|
||||
def processAppstreamFile(appstreamFileName, desktopFileName, iconBaseName):
|
||||
# appstreamFileName has the form <id>.appdata.xml or <id>.metainfo.xml, so we
|
||||
# have to strip off two extensions
|
||||
applicationName = os.path.splitext(os.path.splitext(os.path.basename(appstreamFileName))[0])[0]
|
||||
|
||||
data = {}
|
||||
# Within this file we look at every entry, and where possible try to export it's content so we can use it later
|
||||
appstreamFile = open(appstreamFileName, "rb")
|
||||
root = ET.fromstring(appstreamFile.read())
|
||||
|
||||
allLanguages = set()
|
||||
listAllLanguages(root, allLanguages)
|
||||
|
||||
for child in root:
|
||||
# Make sure we start with a blank slate for this entry
|
||||
output = {}
|
||||
|
||||
# Grab the name of this particular attribute we're looking at
|
||||
# Within the Fastlane specification, it is possible to have several items with the same name but as different types
|
||||
# We therefore include this within our extracted name for the attribute to differentiate them
|
||||
tag = child.tag
|
||||
if 'type' in child.attrib:
|
||||
tag += '-' + child.attrib['type']
|
||||
|
||||
# Have we found some information already for this particular attribute?
|
||||
if tag in data:
|
||||
output = data[tag]
|
||||
|
||||
# Are we dealing with category information here?
|
||||
# If so, then we need to look into this items children to find out all the categories this APK belongs in
|
||||
if tag == 'categories':
|
||||
cats = []
|
||||
for x in child:
|
||||
cats.append(x.text)
|
||||
output = { None: cats }
|
||||
|
||||
# screenshot links
|
||||
elif tag == 'screenshots':
|
||||
output = []
|
||||
for screenshot in child:
|
||||
if screenshot.tag == 'screenshot':
|
||||
for image in screenshot:
|
||||
if image.tag == 'image':
|
||||
output.append(image.text)
|
||||
|
||||
# Otherwise this is just textual information we need to extract
|
||||
else:
|
||||
readText(child, output, allLanguages)
|
||||
|
||||
# Save the information we've gathered!
|
||||
data[tag] = output
|
||||
|
||||
applyLanguageFallback(data['name'], allLanguages)
|
||||
applyLanguageFallback(data['summary'], allLanguages)
|
||||
applyLanguageFallback(data['description'], allLanguages)
|
||||
|
||||
# Did we find any categories?
|
||||
# Sometimes we don't find any within the Fastlane information, but without categories the F-Droid store isn't of much use
|
||||
# In the event this happens, fallback to the *.desktop file for the application to see if it can provide any insight.
|
||||
if not 'categories' in data and desktopFileName:
|
||||
# Parse the XDG format *.desktop file, and extract the categories within it
|
||||
desktopFile = xdg.DesktopEntry.DesktopEntry(desktopFileName)
|
||||
data['categories'] = { None: desktopFile.getCategories() }
|
||||
|
||||
# Try to figure out the source repository
|
||||
if arguments.source and os.path.exists(os.path.join(arguments.source, '.git')):
|
||||
upstream_ref = subprocess.check_output(['git', 'rev-parse', '--symbolic-full-name', '@{u}'], cwd=arguments.source).decode('utf-8')
|
||||
remote = upstream_ref.split('/')[2]
|
||||
output = subprocess.check_output(['git', 'remote', 'get-url', remote], cwd=arguments.source).decode('utf-8')
|
||||
data['source-repo'] = output.strip()
|
||||
|
||||
# write meta data
|
||||
createFastlaneFile( applicationName, "title.txt", data['name'] )
|
||||
createFastlaneFile( applicationName, "short_description.txt", data['summary'] )
|
||||
createFastlaneFile( applicationName, "full_description.txt", data['description'] )
|
||||
createYml(applicationName, data)
|
||||
|
||||
# cleanup old image files before collecting new ones
|
||||
imagePath = os.path.join(arguments.output, 'metadata', applicationName, 'en-US', 'images')
|
||||
shutil.rmtree(imagePath, ignore_errors=True)
|
||||
processLocalImages(applicationName, data)
|
||||
downloadScreenshots(applicationName, data)
|
||||
findIcon(applicationName, iconBaseName)
|
||||
|
||||
# put the result in an archive file for easier use by Jenkins
|
||||
createMetadataArchive(applicationName)
|
||||
|
||||
# scan source directory for manifests/metadata we can work with
|
||||
def scanSourceDir():
|
||||
files = glob.iglob(arguments.source + "/**/AndroidManifest.xml*", recursive=True)
|
||||
for file in files:
|
||||
# third-party libraries might contain AndroidManifests which we are not interested in
|
||||
if "3rdparty" in file:
|
||||
continue
|
||||
|
||||
# find application id from manifest files
|
||||
root = ET.parse(file)
|
||||
appname = root.getroot().attrib['package']
|
||||
is_app = False
|
||||
prefix = '{http://schemas.android.com/apk/res/android}'
|
||||
for md in root.findall("application/activity/meta-data"):
|
||||
if md.attrib[prefix + 'name'] == 'android.app.lib_name':
|
||||
is_app = True
|
||||
|
||||
if not appname or not is_app:
|
||||
continue
|
||||
|
||||
iconBaseName = None
|
||||
for elem in root.findall('application'):
|
||||
if prefix + 'icon' in elem.attrib:
|
||||
iconBaseName = elem.attrib[prefix + 'icon'].split('/')[-1]
|
||||
|
||||
# now that we have the app id, look for matching appdata/desktop files
|
||||
appdataFiles = glob.glob(arguments.source + "/**/" + appname + ".metainfo.xml", recursive=True)
|
||||
appdataFiles.extend(glob.glob(arguments.source + "/**/" + appname + ".appdata.xml", recursive=True))
|
||||
appdataFile = None
|
||||
for f in appdataFiles:
|
||||
appdataFile = f
|
||||
break
|
||||
if not appdataFile:
|
||||
continue
|
||||
|
||||
desktopFiles = glob.iglob(arguments.source + "/**/" + appname + ".desktop", recursive=True)
|
||||
desktopFile = None
|
||||
for f in desktopFiles:
|
||||
desktopFile = f
|
||||
break
|
||||
|
||||
processAppstreamFile(appdataFile, desktopFile, iconBaseName)
|
||||
|
||||
|
||||
### Script Commences
|
||||
|
||||
# Parse the command line arguments we've been given
|
||||
parser = argparse.ArgumentParser(description='Generate fastlane metadata for Android apps from appstream metadata')
|
||||
parser.add_argument('--appstream', type=str, required=False, help='Appstream file to extract metadata from')
|
||||
parser.add_argument('--desktop', type=str, required=False, help='Desktop file to extract additional metadata from')
|
||||
parser.add_argument('--source', type=str, required=False, help='Source directory to find metadata in')
|
||||
parser.add_argument('--output', type=str, required=True, help='Path to which the metadata output should be written to')
|
||||
arguments = parser.parse_args()
|
||||
|
||||
# ensure the output path exists
|
||||
os.makedirs(arguments.output, exist_ok=True)
|
||||
|
||||
# if we have an appstream file explicitly specified, let's use that one
|
||||
if arguments.appstream and os.path.exists(arguments.appstream):
|
||||
processAppstreamFile(arguments.appstream, arguments.desktop)
|
||||
sys.exit(0)
|
||||
|
||||
# else, look in the source dir for appstream/desktop files
|
||||
# this follows roughly what get-apk-args from binary factory does
|
||||
if arguments.source and os.path.exists(arguments.source):
|
||||
scanSourceDir()
|
||||
sys.exit(0)
|
||||
|
||||
# else: missing arguments
|
||||
print("Either one of --appstream or --source have to be provided!")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user