#!/usr/bin/env python3
#
# metadata.py - part of the FDroid server tools
# Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
# Copyright (C) 2013-2014 Daniel Martí
')
def end(self):
self.endcur()
self.text_txt = self.text.getvalue()
self.text_html = self.html.getvalue()
self.text.close()
self.html.close()
# Parse multiple lines of description as written in a metadata file, returning
# a single string in text format and wrapped to 80 columns.
def description_txt(s):
ps = DescriptionFormatter(None)
for line in s.splitlines():
ps.parseline(line)
ps.end()
return ps.text_txt
# Parse multiple lines of description as written in a metadata file, returning
# a single string in wiki format. Used for the Maintainer Notes field as well,
# because it's the same format.
def description_wiki(s):
return s
# Parse multiple lines of description as written in a metadata file, returning
# a single string in HTML format.
def description_html(s, linkres):
ps = DescriptionFormatter(linkres)
for line in s.splitlines():
ps.parseline(line)
ps.end()
return ps.text_html
def parse_txt_srclib(metadatapath):
thisinfo = {}
# Defaults for fields that come from metadata
thisinfo['RepoType'] = ''
thisinfo['Repo'] = ''
thisinfo['Subdir'] = None
thisinfo['Prepare'] = None
if not os.path.exists(metadatapath):
return thisinfo
metafile = open(metadatapath, "r")
n = 0
for line in metafile:
n += 1
line = line.rstrip('\r\n')
if not line or line.startswith("#"):
continue
try:
f, v = line.split(':', 1)
except ValueError:
warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
# collapse whitespaces in field names
f = f.replace(' ', '')
if f == "Subdir":
thisinfo[f] = v.split(',')
else:
thisinfo[f] = v
metafile.close()
return thisinfo
def parse_yaml_srclib(metadatapath):
thisinfo = {'RepoType': '',
'Repo': '',
'Subdir': None,
'Prepare': None}
if not os.path.exists(metadatapath):
warn_or_exception(_("Invalid scrlib metadata: '{file}' "
"does not exist"
.format(file=metadatapath)))
return thisinfo
with open(metadatapath, "r", encoding="utf-8") as f:
try:
data = yaml.load(f, Loader=SafeLoader)
if type(data) is not dict:
raise yaml.error.YAMLError(_('{file} is blank or corrupt!')
.format(file=metadatapath))
except yaml.error.YAMLError as e:
warn_or_exception(_("Invalid srclib metadata: could not "
"parse '{file}'")
.format(file=metadatapath) + '\n'
+ fdroidserver.common.run_yamllint(metadatapath,
indent=4),
cause=e)
return thisinfo
for key in data.keys():
if key not in thisinfo.keys():
warn_or_exception(_("Invalid srclib metadata: unknown key "
"'{key}' in '{file}'")
.format(key=key, file=metadatapath))
return thisinfo
else:
if key == 'Subdir':
if isinstance(data[key], str):
thisinfo[key] = data[key].split(',')
elif isinstance(data[key], list):
thisinfo[key] = data[key]
elif data[key] is None:
thisinfo[key] = ['']
elif key == 'Prepare' and isinstance(data[key], list):
thisinfo[key] = ' && '.join(data[key])
else:
thisinfo[key] = str(data[key] or '')
return thisinfo
def read_srclibs():
"""Read all srclib metadata.
The information read will be accessible as metadata.srclibs, which is a
dictionary, keyed on srclib name, with the values each being a dictionary
in the same format as that returned by the parse_txt_srclib function.
A MetaDataException is raised if there are any problems with the srclib
metadata.
"""
global srclibs
# They were already loaded
if srclibs is not None:
return
srclibs = {}
srcdir = 'srclibs'
if not os.path.exists(srcdir):
os.makedirs(srcdir)
for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
srclibname = os.path.basename(metadatapath[:-4])
srclibs[srclibname] = parse_txt_srclib(metadatapath)
for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.yml'))):
srclibname = os.path.basename(metadatapath[:-4])
srclibs[srclibname] = parse_yaml_srclib(metadatapath)
def read_metadata(xref=True, check_vcs=[], refresh=True, sort_by_time=False):
"""Return a list of App instances sorted newest first
This reads all of the metadata files in a 'data' repository, then
builds a list of App instances from those files. The list is
sorted based on creation time, newest first. Most of the time,
the newer files are the most interesting.
If there are multiple metadata files for a single appid, then the first
file that is parsed wins over all the others, and the rest throw an
exception. So the original .txt format is parsed first, at least until
newer formats stabilize.
check_vcs is the list of appids to check for .fdroid.yml in source
"""
# Always read the srclibs before the apps, since they can use a srlib as
# their source repository.
read_srclibs()
apps = OrderedDict()
for basedir in ('metadata', 'tmp'):
if not os.path.exists(basedir):
os.makedirs(basedir)
metadatafiles = (glob.glob(os.path.join('metadata', '*.txt'))
+ glob.glob(os.path.join('metadata', '*.json'))
+ glob.glob(os.path.join('metadata', '*.yml'))
+ glob.glob('.fdroid.txt')
+ glob.glob('.fdroid.json')
+ glob.glob('.fdroid.yml'))
if sort_by_time:
entries = ((os.stat(path).st_mtime, path) for path in metadatafiles)
metadatafiles = []
for _ignored, path in sorted(entries, reverse=True):
metadatafiles.append(path)
else:
# most things want the index alpha sorted for stability
metadatafiles = sorted(metadatafiles)
for metadatapath in metadatafiles:
if metadatapath == '.fdroid.txt':
warn_or_exception(_('.fdroid.txt is not supported! Convert to .fdroid.yml or .fdroid.json.'))
appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
if appid != '.fdroid' and not fdroidserver.common.is_valid_package_name(appid):
warn_or_exception(_("{appid} from {path} is not a valid Java Package Name!")
.format(appid=appid, path=metadatapath))
if appid in apps:
warn_or_exception(_("Found multiple metadata files for {appid}")
.format(appid=appid))
app = parse_metadata(metadatapath, appid in check_vcs, refresh)
check_metadata(app)
apps[app.id] = app
if xref:
# Parse all descriptions at load time, just to ensure cross-referencing
# errors are caught early rather than when they hit the build server.
def linkres(appid):
if appid in apps:
return ("fdroid.app:" + appid, "Dummy name - don't know yet")
warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
for appid, app in apps.items():
try:
description_html(app.Description, linkres)
except MetaDataException as e:
warn_or_exception(_("Problem with description of {appid}: {error}")
.format(appid=appid, error=str(e)))
return apps
# Port legacy ';' separators
list_sep = re.compile(r'[,;]')
def split_list_values(s):
res = []
for v in re.split(list_sep, s):
if not v:
continue
v = v.strip()
if not v:
continue
res.append(v)
return res
def sorted_builds(builds):
return sorted(builds, key=lambda build: int(build.versionCode))
esc_newlines = re.compile(r'\\( |\n)')
def post_metadata_parse(app):
# TODO keep native types, convert only for .txt metadata
for k, v in app.items():
if type(v) in (float, int):
app[k] = str(v)
if 'Builds' in app:
app['builds'] = app.pop('Builds')
if 'flavours' in app and app['flavours'] == [True]:
app['flavours'] = 'yes'
for field, fieldtype in fieldtypes.items():
if fieldtype != TYPE_LIST:
continue
value = app.get(field)
if isinstance(value, str):
app[field] = [value, ]
elif value is not None:
app[field] = [str(i) for i in value]
def _yaml_bool_unmapable(v):
return v in (True, False, [True], [False])
def _yaml_bool_unmap(v):
if v is True:
return 'yes'
elif v is False:
return 'no'
elif v == [True]:
return ['yes']
elif v == [False]:
return ['no']
_bool_allowed = ('maven', 'buildozer')
builds = []
if 'builds' in app:
for build in app['builds']:
if not isinstance(build, Build):
build = Build(build)
for k, v in build.items():
if not (v is None):
if flagtype(k) == TYPE_LIST:
if _yaml_bool_unmapable(v):
build[k] = _yaml_bool_unmap(v)
if isinstance(v, str):
build[k] = [v]
elif isinstance(v, bool):
if v:
build[k] = ['yes']
else:
build[k] = []
elif flagtype(k) is TYPE_INT:
build[k] = str(v)
elif flagtype(k) is TYPE_STRING:
if isinstance(v, bool) and k in _bool_allowed:
build[k] = v
else:
if _yaml_bool_unmapable(v):
build[k] = _yaml_bool_unmap(v)
else:
build[k] = str(v)
builds.append(build)
app.builds = sorted_builds(builds)
# Parse metadata for a single application.
#
# 'metadatapath' - the filename to read. The "Application ID" aka
# "Package Name" for the application comes from this
# filename. Pass None to get a blank entry.
#
# Returns a dictionary containing all the details of the application. There are
# two major kinds of information in the dictionary. Keys beginning with capital
# letters correspond directory to identically named keys in the metadata file.
# Keys beginning with lower case letters are generated in one way or another,
# and are not found verbatim in the metadata.
#
# Known keys not originating from the metadata are:
#
# 'builds' - a list of dictionaries containing build information
# for each defined build
# 'comments' - a list of comments from the metadata file. Each is
# a list of the form [field, comment] where field is
# the name of the field it preceded in the metadata
# file. Where field is None, the comment goes at the
# end of the file. Alternatively, 'build:version' is
# for a comment before a particular build version.
# 'descriptionlines' - original lines of description as formatted in the
# metadata file.
#
bool_true = re.compile(r'([Yy]es|[Tt]rue)')
bool_false = re.compile(r'([Nn]o|[Ff]alse)')
def _decode_bool(s):
if bool_true.match(s):
return True
if bool_false.match(s):
return False
warn_or_exception(_("Invalid boolean '%s'") % s)
def parse_metadata(metadatapath, check_vcs=False, refresh=True):
'''parse metadata file, optionally checking the git repo for metadata first'''
_ignored, ext = fdroidserver.common.get_extension(metadatapath)
accepted = fdroidserver.common.config['accepted_formats']
if ext not in accepted:
warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
.format(path=metadatapath, formats=', '.join(accepted)))
app = App()
app.metadatapath = metadatapath
name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
if name == '.fdroid':
check_vcs = False
else:
app.id = name
with open(metadatapath, 'r') as mf:
if ext == 'txt':
parse_txt_metadata(mf, app)
elif ext == 'json':
parse_json_metadata(mf, app)
elif ext == 'yml':
parse_yaml_metadata(mf, app)
else:
warn_or_exception(_('Unknown metadata format: {path}')
.format(path=metadatapath))
if check_vcs and app.Repo:
build_dir = fdroidserver.common.get_build_dir(app)
metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
if not os.path.isfile(metadata_in_repo):
vcs, build_dir = fdroidserver.common.setup_vcs(app)
if isinstance(vcs, fdroidserver.common.vcs_git):
vcs.gotorevision('HEAD', refresh) # HEAD since we can't know where else to go
if os.path.isfile(metadata_in_repo):
logging.debug('Including metadata from ' + metadata_in_repo)
# do not include fields already provided by main metadata file
app_in_repo = parse_metadata(metadata_in_repo)
for k, v in app_in_repo.items():
if k not in app:
app[k] = v
post_metadata_parse(app)
if not app.id:
if app.builds:
build = app.builds[-1]
if build.subdir:
root_dir = build.subdir
else:
root_dir = '.'
paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
_ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
return app
def parse_json_metadata(mf, app):
# fdroid metadata is only strings and booleans, no floats or ints.
# TODO create schema using https://pypi.python.org/pypi/jsonschema
jsoninfo = json.load(mf, parse_int=lambda s: s,
parse_float=lambda s: s)
app.update(jsoninfo)
for f in ['Description', 'Maintainer Notes']:
v = app.get(f)
if v:
app[f] = '\n'.join(v)
return app
def parse_yaml_metadata(mf, app):
try:
yamldata = yaml.load(mf, Loader=SafeLoader)
except yaml.YAMLError as e:
warn_or_exception(_("could not parse '{path}'")
.format(path=mf.name) + '\n'
+ fdroidserver.common.run_yamllint(mf.name,
indent=4),
cause=e)
deprecated_in_yaml = ['Provides']
if yamldata:
for field in yamldata:
if field not in yaml_app_fields:
if field not in deprecated_in_yaml:
warn_or_exception(_("Unrecognised app field "
"'{fieldname}' in '{path}'")
.format(fieldname=field,
path=mf.name))
for deprecated_field in deprecated_in_yaml:
if deprecated_field in yamldata:
logging.warning(_("Ignoring '{field}' in '{metapath}' "
"metadata because it is deprecated.")
.format(field=deprecated_field,
metapath=mf.name))
del(yamldata[deprecated_field])
if yamldata.get('Builds', None):
for build in yamldata.get('Builds', []):
# put all build flag keywords into a set to avoid
# excessive looping action
build_flag_set = set()
for build_flag in build.keys():
build_flag_set.add(build_flag)
for build_flag in build_flag_set:
if build_flag not in build_flags:
warn_or_exception(
_("Unrecognised build flag '{build_flag}' "
"in '{path}'").format(build_flag=build_flag,
path=mf.name))
post_parse_yaml_metadata(yamldata)
app.update(yamldata)
return app
def post_parse_yaml_metadata(yamldata):
"""transform yaml metadata to our internal data format"""
for build in yamldata.get('Builds', []):
for flag in build.keys():
_flagtype = flagtype(flag)
if _flagtype is TYPE_SCRIPT:
# concatenate script flags into a single string if they are stored as list
if isinstance(build[flag], list):
build[flag] = ' && '.join(build[flag])
elif _flagtype is TYPE_STRING:
# things like versionNames are strings, but without quotes can be numbers
if isinstance(build[flag], float) or isinstance(build[flag], int):
build[flag] = str(build[flag])
elif _flagtype is TYPE_INT:
# versionCode must be int
if not isinstance(build[flag], int):
warn_or_exception(_('{build_flag} must be an integer, found: {value}')
.format(build_flag=flag, value=build[flag]))
def write_yaml(mf, app):
"""Write metadata in yaml format.
:param mf: active file discriptor for writing
:param app: app metadata to written to the yaml file
"""
# import rumael.yaml and check version
try:
import ruamel.yaml
except ImportError as e:
raise FDroidException('ruamel.yaml not installed, can not write metadata.') from e
if not ruamel.yaml.__version__:
raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
m = re.match(r'(?P