3 # metadata.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013-2014 Daniel Martà <mvdan@mvdan.cc>
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
30 # use libyaml if it is available
32 from yaml import CLoader
35 from yaml import Loader
38 import fdroidserver.common
41 warnings_action = None
44 class MetaDataException(Exception):
46 def __init__(self, value):
53 def warn_or_exception(value):
54 '''output warning or Exception depending on -W'''
55 if warnings_action == 'ignore':
57 elif warnings_action == 'error':
58 raise MetaDataException(value)
63 # To filter which ones should be written to the metadata files if
94 'Update Check Ignore',
99 'Current Version Code',
103 'comments', # For formats that don't do inline comments
104 'builds', # For formats that do builds as a list
110 def __init__(self, copydict=None):
112 super().__init__(copydict)
117 self.AntiFeatures = []
119 self.Categories = ['None']
120 self.License = 'Unknown'
121 self.AuthorName = None
122 self.AuthorEmail = None
123 self.AuthorWebSite = None
126 self.IssueTracker = ''
135 self.Description = ''
136 self.RequiresRoot = False
140 self.MaintainerNotes = ''
141 self.ArchivePolicy = None
142 self.AutoUpdateMode = 'None'
143 self.UpdateCheckMode = 'None'
144 self.UpdateCheckIgnore = None
145 self.VercodeOperation = None
146 self.UpdateCheckName = None
147 self.UpdateCheckData = None
148 self.CurrentVersion = ''
149 self.CurrentVersionCode = None
150 self.NoSourceSince = ''
153 self.metadatapath = None
157 self.lastUpdated = None
159 def __getattr__(self, name):
163 raise AttributeError("No such attribute: " + name)
165 def __setattr__(self, name, value):
168 def __delattr__(self, name):
172 raise AttributeError("No such attribute: " + name)
174 def get_last_build(self):
175 if len(self.builds) > 0:
176 return self.builds[-1]
192 'Description': TYPE_MULTILINE,
193 'MaintainerNotes': TYPE_MULTILINE,
194 'Categories': TYPE_LIST,
195 'AntiFeatures': TYPE_LIST,
196 'BuildVersion': TYPE_BUILD,
197 'Build': TYPE_BUILD_V2,
198 'UseBuilt': TYPE_OBSOLETE,
203 name = name.replace(' ', '')
204 if name in fieldtypes:
205 return fieldtypes[name]
209 # In the order in which they are laid out on files
210 build_flags_order = [
243 build_flags = set(build_flags_order + ['versionName', 'versionCode'])
248 def __init__(self, copydict=None):
253 self.submodules = False
261 self.oldsdkloc = False
263 self.forceversion = False
264 self.forcevercode = False
268 self.androidupdate = []
275 self.preassemble = []
276 self.gradleprops = []
277 self.antcommands = []
278 self.novcheck = False
280 super().__init__(copydict)
283 def __getattr__(self, name):
287 raise AttributeError("No such attribute: " + name)
289 def __setattr__(self, name, value):
292 def __delattr__(self, name):
296 raise AttributeError("No such attribute: " + name)
298 def build_method(self):
299 for f in ['maven', 'gradle', 'kivy']:
306 # like build_method, but prioritize output=
307 def output_method(self):
310 for f in ['maven', 'gradle', 'kivy']:
318 version = 'r12b' # falls back to latest
319 paths = fdroidserver.common.config['ndk_paths']
320 if version not in paths:
322 return paths[version]
326 'extlibs': TYPE_LIST,
327 'srclibs': TYPE_LIST,
330 'buildjni': TYPE_LIST,
331 'preassemble': TYPE_LIST,
332 'androidupdate': TYPE_LIST,
333 'scanignore': TYPE_LIST,
334 'scandelete': TYPE_LIST,
336 'antcommands': TYPE_LIST,
337 'gradleprops': TYPE_LIST,
339 'prebuild': TYPE_SCRIPT,
340 'build': TYPE_SCRIPT,
341 'submodules': TYPE_BOOL,
342 'oldsdkloc': TYPE_BOOL,
343 'forceversion': TYPE_BOOL,
344 'forcevercode': TYPE_BOOL,
345 'novcheck': TYPE_BOOL,
350 if name in flagtypes:
351 return flagtypes[name]
355 class FieldValidator():
357 Designates App metadata field types and checks that it matches
359 'name' - The long name of the field type
360 'matching' - List of possible values or regex expression
361 'sep' - Separator to use if value may be a list
362 'fields' - Metadata fields (Field:Value) of this type
365 def __init__(self, name, matching, fields):
367 self.matching = matching
368 self.compiled = re.compile(matching)
371 def check(self, v, appid):
379 if not self.compiled.match(v):
380 warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
381 % (v, self.name, appid, self.matching))
384 # Generic value types
386 FieldValidator("Hexadecimal",
390 FieldValidator("HTTP link",
392 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
394 FieldValidator("Email",
395 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
398 FieldValidator("Bitcoin address",
399 r'^[a-zA-Z0-9]{27,34}$',
402 FieldValidator("Litecoin address",
403 r'^L[a-zA-Z0-9]{33}$',
406 FieldValidator("Repo Type",
407 r'^(git|git-svn|svn|hg|bzr|srclib)$',
410 FieldValidator("Binaries",
414 FieldValidator("Archive Policy",
415 r'^[0-9]+ versions$',
418 FieldValidator("Anti-Feature",
419 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln)$',
422 FieldValidator("Auto Update Mode",
423 r"^(Version .+|None)$",
426 FieldValidator("Update Check Mode",
427 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
432 # Check an app's metadata information for integrity errors
433 def check_metadata(app):
436 v.check(app[k], app.id)
439 # Formatter for descriptions. Create an instance, and call parseline() with
440 # each line of the description source from the metadata. At the end, call
441 # end() and then text_txt and text_html will contain the result.
442 class DescriptionFormatter:
449 def __init__(self, linkres):
452 self.state = self.stNONE
453 self.laststate = self.stNONE
456 self.html = io.StringIO()
457 self.text = io.StringIO()
459 self.linkResolver = None
460 self.linkResolver = linkres
462 def endcur(self, notstates=None):
463 if notstates and self.state in notstates:
465 if self.state == self.stPARA:
467 elif self.state == self.stUL:
469 elif self.state == self.stOL:
473 self.laststate = self.state
474 self.state = self.stNONE
475 whole_para = ' '.join(self.para_lines)
476 self.addtext(whole_para)
477 wrapped = textwrap.fill(whole_para, 80,
478 break_long_words=False,
479 break_on_hyphens=False)
480 self.text.write(wrapped)
481 self.html.write('</p>')
482 del self.para_lines[:]
485 self.html.write('</ul>')
486 self.laststate = self.state
487 self.state = self.stNONE
490 self.html.write('</ol>')
491 self.laststate = self.state
492 self.state = self.stNONE
494 def formatted(self, txt, html):
497 txt = cgi.escape(txt)
499 index = txt.find("''")
504 if txt.startswith("'''"):
510 self.bold = not self.bold
518 self.ital = not self.ital
521 def linkify(self, txt):
525 index = txt.find("[")
527 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
528 res_plain += self.formatted(txt[:index], False)
529 res_html += self.formatted(txt[:index], True)
531 if txt.startswith("[["):
532 index = txt.find("]]")
534 warn_or_exception("Unterminated ]]")
536 if self.linkResolver:
537 url, urltext = self.linkResolver(url)
540 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
542 txt = txt[index + 2:]
544 index = txt.find("]")
546 warn_or_exception("Unterminated ]")
548 index2 = url.find(' ')
552 urltxt = url[index2 + 1:]
555 warn_or_exception("Url title is just the URL - use [url]")
556 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
559 res_plain += ' (' + url + ')'
560 txt = txt[index + 1:]
562 def addtext(self, txt):
563 p, h = self.linkify(txt)
566 def parseline(self, line):
569 elif line.startswith('* '):
570 self.endcur([self.stUL])
571 if self.state != self.stUL:
572 self.html.write('<ul>')
573 self.state = self.stUL
574 if self.laststate != self.stNONE:
575 self.text.write('\n\n')
577 self.text.write('\n')
578 self.text.write(line)
579 self.html.write('<li>')
580 self.addtext(line[1:])
581 self.html.write('</li>')
582 elif line.startswith('# '):
583 self.endcur([self.stOL])
584 if self.state != self.stOL:
585 self.html.write('<ol>')
586 self.state = self.stOL
587 if self.laststate != self.stNONE:
588 self.text.write('\n\n')
590 self.text.write('\n')
591 self.text.write(line)
592 self.html.write('<li>')
593 self.addtext(line[1:])
594 self.html.write('</li>')
596 self.para_lines.append(line)
597 self.endcur([self.stPARA])
598 if self.state == self.stNONE:
599 self.state = self.stPARA
600 if self.laststate != self.stNONE:
601 self.text.write('\n\n')
602 self.html.write('<p>')
606 self.text_txt = self.text.getvalue()
607 self.text_html = self.html.getvalue()
612 # Parse multiple lines of description as written in a metadata file, returning
613 # a single string in text format and wrapped to 80 columns.
614 def description_txt(s):
615 ps = DescriptionFormatter(None)
616 for line in s.splitlines():
622 # Parse multiple lines of description as written in a metadata file, returning
623 # a single string in wiki format. Used for the Maintainer Notes field as well,
624 # because it's the same format.
625 def description_wiki(s):
629 # Parse multiple lines of description as written in a metadata file, returning
630 # a single string in HTML format.
631 def description_html(s, linkres):
632 ps = DescriptionFormatter(linkres)
633 for line in s.splitlines():
639 def parse_srclib(metadatapath):
643 # Defaults for fields that come from metadata
644 thisinfo['Repo Type'] = ''
645 thisinfo['Repo'] = ''
646 thisinfo['Subdir'] = None
647 thisinfo['Prepare'] = None
649 if not os.path.exists(metadatapath):
652 metafile = open(metadatapath, "r", encoding='utf-8')
655 for line in metafile:
657 line = line.rstrip('\r\n')
658 if not line or line.startswith("#"):
662 f, v = line.split(':', 1)
664 warn_or_exception("Invalid metadata in %s:%d" % (line, n))
667 thisinfo[f] = v.split(',')
677 """Read all srclib metadata.
679 The information read will be accessible as metadata.srclibs, which is a
680 dictionary, keyed on srclib name, with the values each being a dictionary
681 in the same format as that returned by the parse_srclib function.
683 A MetaDataException is raised if there are any problems with the srclib
688 # They were already loaded
689 if srclibs is not None:
695 if not os.path.exists(srcdir):
698 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
699 srclibname = os.path.basename(metadatapath[:-4])
700 srclibs[srclibname] = parse_srclib(metadatapath)
703 def read_metadata(xref=True, check_vcs=[]):
705 Read all metadata. Returns a list of 'app' objects (which are dictionaries as
706 returned by the parse_txt_metadata function.
708 check_vcs is the list of packageNames to check for .fdroid.yml in source
711 # Always read the srclibs before the apps, since they can use a srlib as
712 # their source repository.
717 for basedir in ('metadata', 'tmp'):
718 if not os.path.exists(basedir):
721 # If there are multiple metadata files for a single appid, then the first
722 # file that is parsed wins over all the others, and the rest throw an
723 # exception. So the original .txt format is parsed first, at least until
724 # newer formats stabilize.
726 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
727 + glob.glob(os.path.join('metadata', '*.json'))
728 + glob.glob(os.path.join('metadata', '*.yml'))
729 + glob.glob('.fdroid.json')
730 + glob.glob('.fdroid.yml')):
731 packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
732 if packageName in apps:
733 warn_or_exception("Found multiple metadata files for " + packageName)
734 app = parse_metadata(metadatapath, packageName in check_vcs)
739 # Parse all descriptions at load time, just to ensure cross-referencing
740 # errors are caught early rather than when they hit the build server.
743 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
744 warn_or_exception("Cannot resolve app id " + appid)
746 for appid, app in apps.items():
748 description_html(app.Description, linkres)
749 except MetaDataException as e:
750 warn_or_exception("Problem with description of " + appid +
756 # Port legacy ';' separators
757 list_sep = re.compile(r'[,;]')
760 def split_list_values(s):
762 for v in re.split(list_sep, s):
772 def get_default_app_info(metadatapath=None):
773 if metadatapath is None:
776 appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
778 if appid == '.fdroid': # we have local metadata in the app's source
779 if os.path.exists('AndroidManifest.xml'):
780 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
782 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
783 for root, dirs, files in os.walk(os.getcwd()):
784 if 'build.gradle' in files:
785 p = os.path.join(root, 'build.gradle')
786 with open(p, 'rb') as f:
788 m = pattern.search(data)
790 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
791 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
793 if manifestroot is None:
794 warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
795 appid = manifestroot.attrib['package']
798 app.metadatapath = metadatapath
799 if appid is not None:
805 def sorted_builds(builds):
806 return sorted(builds, key=lambda build: int(build.versionCode))
809 esc_newlines = re.compile(r'\\( |\n)')
812 def post_metadata_parse(app):
813 # TODO keep native types, convert only for .txt metadata
814 for k, v in app.items():
815 if type(v) in (float, int):
818 if isinstance(app.Categories, str):
819 app.Categories = [app.Categories]
820 elif app.Categories is None:
821 app.Categories = ['None']
823 app.Categories = [str(i) for i in app.Categories]
827 for build in app['builds']:
828 if not isinstance(build, Build):
830 for k, v in build.items():
831 if flagtype(k) == TYPE_LIST:
832 if isinstance(v, str):
834 elif isinstance(v, bool):
839 elif flagtype(k) == TYPE_STRING and type(v) in (float, int):
843 app.builds = sorted_builds(builds)
846 # Parse metadata for a single application.
848 # 'metadatapath' - the filename to read. The package id for the application comes
849 # from this filename. Pass None to get a blank entry.
851 # Returns a dictionary containing all the details of the application. There are
852 # two major kinds of information in the dictionary. Keys beginning with capital
853 # letters correspond directory to identically named keys in the metadata file.
854 # Keys beginning with lower case letters are generated in one way or another,
855 # and are not found verbatim in the metadata.
857 # Known keys not originating from the metadata are:
859 # 'builds' - a list of dictionaries containing build information
860 # for each defined build
861 # 'comments' - a list of comments from the metadata file. Each is
862 # a list of the form [field, comment] where field is
863 # the name of the field it preceded in the metadata
864 # file. Where field is None, the comment goes at the
865 # end of the file. Alternatively, 'build:version' is
866 # for a comment before a particular build version.
867 # 'descriptionlines' - original lines of description as formatted in the
872 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
873 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
877 if bool_true.match(s):
879 if bool_false.match(s):
881 warn_or_exception("Invalid bool '%s'" % s)
884 def parse_metadata(metadatapath, check_vcs=False):
885 '''parse metadata file, optionally checking the git repo for metadata first'''
887 _, ext = fdroidserver.common.get_extension(metadatapath)
888 accepted = fdroidserver.common.config['accepted_formats']
889 if ext not in accepted:
890 warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
891 metadatapath, ', '.join(accepted)))
894 app.metadatapath = metadatapath
895 name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
896 if name == '.fdroid':
901 with open(metadatapath, 'r', encoding='utf-8') as mf:
903 parse_txt_metadata(mf, app)
905 parse_json_metadata(mf, app)
907 parse_yaml_metadata(mf, app)
909 warn_or_exception('Unknown metadata format: %s' % metadatapath)
911 if check_vcs and app.Repo:
912 build_dir = fdroidserver.common.get_build_dir(app)
913 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
914 if not os.path.isfile(metadata_in_repo):
915 vcs, build_dir = fdroidserver.common.setup_vcs(app)
916 if isinstance(vcs, fdroidserver.common.vcs_git):
917 vcs.gotorevision('HEAD') # HEAD since we can't know where else to go
918 if os.path.isfile(metadata_in_repo):
919 logging.debug('Including metadata from ' + metadata_in_repo)
920 # do not include fields already provided by main metadata file
921 app_in_repo = parse_metadata(metadata_in_repo)
922 for k, v in app_in_repo.items():
926 post_metadata_parse(app)
930 build = app.builds[-1]
932 root_dir = build.subdir
935 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
936 _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
941 def parse_json_metadata(mf, app):
943 # fdroid metadata is only strings and booleans, no floats or ints.
944 # TODO create schema using https://pypi.python.org/pypi/jsonschema
945 jsoninfo = json.load(mf, parse_int=lambda s: s,
946 parse_float=lambda s: s)
948 for f in ['Description', 'Maintainer Notes']:
951 app[f] = '\n'.join(v)
955 def parse_yaml_metadata(mf, app):
957 yamlinfo = yaml.load(mf, Loader=YamlLoader)
962 build_line_sep = re.compile(r'(?<!\\),')
963 build_cont = re.compile(r'^[ \t]')
966 def parse_txt_metadata(mf, app):
970 def add_buildflag(p, build):
972 warn_or_exception("Empty build flag at {1}"
973 .format(buildlines[0], linedesc))
976 warn_or_exception("Invalid build flag at {0} in {1}"
977 .format(buildlines[0], linedesc))
982 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
985 pv = split_list_values(pv)
987 elif t == TYPE_STRING or t == TYPE_SCRIPT:
990 build[pk] = _decode_bool(pv)
992 def parse_buildline(lines):
994 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
996 warn_or_exception("Invalid build format: " + v + " in " + mf.name)
998 build.versionName = parts[0]
999 build.versionCode = parts[1]
1000 check_versionCode(build.versionCode)
1002 if parts[2].startswith('!'):
1003 # For backwards compatibility, handle old-style disabling,
1004 # including attempting to extract the commit from the message
1005 build.disable = parts[2][1:]
1006 commit = 'unknown - see disabled'
1007 index = parts[2].rfind('at ')
1009 commit = parts[2][index + 3:]
1010 if commit.endswith(')'):
1011 commit = commit[:-1]
1012 build.commit = commit
1014 build.commit = parts[2]
1016 add_buildflag(p, build)
1020 def check_versionCode(versionCode):
1024 warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1026 def add_comments(key):
1029 app.comments[key] = list(curcomments)
1034 multiline_lines = []
1044 linedesc = "%s:%d" % (mf.name, c)
1045 line = line.rstrip('\r\n')
1047 if build_cont.match(line):
1048 if line.endswith('\\'):
1049 buildlines.append(line[:-1].lstrip())
1051 buildlines.append(line.lstrip())
1052 bl = ''.join(buildlines)
1053 add_buildflag(bl, build)
1056 if not build.commit and not build.disable:
1057 warn_or_exception("No commit specified for {0} in {1}"
1058 .format(build.versionName, linedesc))
1060 app.builds.append(build)
1061 add_comments('build:' + build.versionCode)
1067 if line.startswith("#"):
1068 curcomments.append(line[1:].strip())
1071 f, v = line.split(':', 1)
1073 warn_or_exception("Invalid metadata in " + linedesc)
1075 if f not in app_fields:
1076 warn_or_exception('Unrecognised app field: ' + f)
1078 # Translate obsolete fields...
1079 if f == 'Market Version':
1080 f = 'Current Version'
1081 if f == 'Market Version Code':
1082 f = 'Current Version Code'
1084 f = f.replace(' ', '')
1086 ftype = fieldtype(f)
1087 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1089 if ftype == TYPE_MULTILINE:
1092 warn_or_exception("Unexpected text on same line as "
1093 + f + " in " + linedesc)
1094 elif ftype == TYPE_STRING:
1096 elif ftype == TYPE_LIST:
1097 app[f] = split_list_values(v)
1098 elif ftype == TYPE_BUILD:
1099 if v.endswith("\\"):
1102 buildlines.append(v[:-1])
1104 build = parse_buildline([v])
1105 app.builds.append(build)
1106 add_comments('build:' + app.builds[-1].versionCode)
1107 elif ftype == TYPE_BUILD_V2:
1110 warn_or_exception('Build should have comma-separated',
1111 'versionName and versionCode,',
1112 'not "{0}", in {1}'.format(v, linedesc))
1114 build.versionName = vv[0]
1115 build.versionCode = vv[1]
1116 check_versionCode(build.versionCode)
1118 if build.versionCode in vc_seen:
1119 warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1120 % (build.versionCode, linedesc))
1121 vc_seen.add(build.versionCode)
1124 elif ftype == TYPE_OBSOLETE:
1125 pass # Just throw it away!
1127 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1128 elif mode == 1: # Multiline field
1131 app[f] = '\n'.join(multiline_lines)
1132 del multiline_lines[:]
1134 multiline_lines.append(line)
1135 elif mode == 2: # Line continuation mode in Build Version
1136 if line.endswith("\\"):
1137 buildlines.append(line[:-1])
1139 buildlines.append(line)
1140 build = parse_buildline(buildlines)
1141 app.builds.append(build)
1142 add_comments('build:' + app.builds[-1].versionCode)
1146 # Mode at end of file should always be 0
1148 warn_or_exception(f + " not terminated in " + mf.name)
1150 warn_or_exception("Unterminated continuation in " + mf.name)
1152 warn_or_exception("Unterminated build in " + mf.name)
1157 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1159 def field_to_attr(f):
1161 Translates human-readable field names to attribute names, e.g.
1162 'Auto Name' to 'AutoName'
1164 return f.replace(' ', '')
1166 def attr_to_field(k):
1168 Translates attribute names to human-readable field names, e.g.
1169 'AutoName' to 'Auto Name'
1173 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1176 def w_comments(key):
1177 if key not in app.comments:
1179 for line in app.comments[key]:
1182 def w_field_always(f, v=None):
1183 key = field_to_attr(f)
1189 def w_field_nonempty(f, v=None):
1190 key = field_to_attr(f)
1197 w_field_nonempty('Disabled')
1198 w_field_nonempty('AntiFeatures')
1199 w_field_nonempty('Provides')
1200 w_field_always('Categories')
1201 w_field_always('License')
1202 w_field_nonempty('Author Name')
1203 w_field_nonempty('Author Email')
1204 w_field_nonempty('Author Web Site')
1205 w_field_always('Web Site')
1206 w_field_always('Source Code')
1207 w_field_always('Issue Tracker')
1208 w_field_nonempty('Changelog')
1209 w_field_nonempty('Donate')
1210 w_field_nonempty('FlattrID')
1211 w_field_nonempty('Bitcoin')
1212 w_field_nonempty('Litecoin')
1214 w_field_nonempty('Name')
1215 w_field_nonempty('Auto Name')
1216 w_field_always('Summary')
1217 w_field_always('Description', description_txt(app.Description))
1219 if app.RequiresRoot:
1220 w_field_always('Requires Root', 'yes')
1223 w_field_always('Repo Type')
1224 w_field_always('Repo')
1226 w_field_always('Binaries')
1229 for build in app.builds:
1231 if build.versionName == "Ignore":
1234 w_comments('build:%s' % build.versionCode)
1238 if app.MaintainerNotes:
1239 w_field_always('Maintainer Notes', app.MaintainerNotes)
1242 w_field_nonempty('Archive Policy')
1243 w_field_always('Auto Update Mode')
1244 w_field_always('Update Check Mode')
1245 w_field_nonempty('Update Check Ignore')
1246 w_field_nonempty('Vercode Operation')
1247 w_field_nonempty('Update Check Name')
1248 w_field_nonempty('Update Check Data')
1249 if app.CurrentVersion:
1250 w_field_always('Current Version')
1251 w_field_always('Current Version Code')
1252 if app.NoSourceSince:
1254 w_field_always('No Source Since')
1258 # Write a metadata file in txt format.
1260 # 'mf' - Writer interface (file, StringIO, ...)
1261 # 'app' - The app data
1262 def write_txt(mf, app):
1264 def w_comment(line):
1265 mf.write("# %s\n" % line)
1271 elif t == TYPE_MULTILINE:
1272 v = '\n' + v + '\n.'
1273 mf.write("%s:%s\n" % (f, v))
1276 mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1278 for f in build_flags_order:
1284 if f == 'androidupdate':
1285 f = 'update' # avoid conflicting with Build(dict).update()
1286 mf.write(' %s=' % f)
1287 if t == TYPE_STRING:
1289 elif t == TYPE_BOOL:
1291 elif t == TYPE_SCRIPT:
1293 for s in v.split(' && '):
1297 mf.write(' && \\\n ')
1299 elif t == TYPE_LIST:
1300 mf.write(','.join(v))
1304 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1307 def write_yaml(mf, app):
1309 def w_comment(line):
1310 mf.write("# %s\n" % line)
1315 if any(c in v for c in [': ', '%', '@', '*']):
1316 return "'" + v.replace("'", "''") + "'"
1319 def w_field(f, v, prefix='', t=None):
1326 v += prefix + ' - ' + escape(e) + '\n'
1327 elif t == TYPE_MULTILINE:
1329 for l in v.splitlines():
1331 v += prefix + ' ' + l + '\n'
1334 elif t == TYPE_BOOL:
1336 elif t == TYPE_SCRIPT:
1337 cmds = [s + '&& \\' for s in v.split('&& ')]
1339 cmds[-1] = cmds[-1][:-len('&& \\')]
1340 w_field(f, cmds, prefix, 'multiline')
1343 v = ' ' + escape(v) + '\n'
1356 mf.write("builds:\n")
1359 w_field('versionName', build.versionName, ' - ', TYPE_STRING)
1360 w_field('versionCode', build.versionCode, ' ', TYPE_STRING)
1361 for f in build_flags_order:
1366 w_field(f, v, ' ', flagtype(f))
1368 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1371 def write_metadata(metadatapath, app):
1372 _, ext = fdroidserver.common.get_extension(metadatapath)
1373 accepted = fdroidserver.common.config['accepted_formats']
1374 if ext not in accepted:
1375 warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1376 % (metadatapath, ', '.join(accepted)))
1378 with open(metadatapath, 'w', encoding='utf8') as mf:
1380 return write_txt(mf, app)
1382 return write_yaml(mf, app)
1383 warn_or_exception('Unknown metadata format: %s' % metadatapath)
1386 def add_metadata_arguments(parser):
1387 '''add common command line flags related to metadata processing'''
1388 parser.add_argument("-W", default='error',
1389 help="force errors to be warnings, or ignore")