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 # use the C implementation when available
39 import xml.etree.cElementTree as ElementTree
41 import fdroidserver.common
44 warnings_action = None
47 class MetaDataException(Exception):
49 def __init__(self, value):
56 def warn_or_exception(value):
57 '''output warning or Exception depending on -W'''
58 if warnings_action == 'ignore':
60 elif warnings_action == 'error':
61 raise MetaDataException(value)
66 # To filter which ones should be written to the metadata files if
96 'Update Check Ignore',
101 'Current Version Code',
104 'comments', # For formats that don't do inline comments
105 'builds', # For formats that do builds as a list
113 self.AntiFeatures = []
115 self.Categories = ['None']
116 self.License = 'Unknown'
117 self.AuthorName = None
118 self.AuthorEmail = None
121 self.IssueTracker = ''
130 self.Description = ''
131 self.RequiresRoot = False
135 self.MaintainerNotes = ''
136 self.ArchivePolicy = None
137 self.AutoUpdateMode = 'None'
138 self.UpdateCheckMode = 'None'
139 self.UpdateCheckIgnore = None
140 self.VercodeOperation = None
141 self.UpdateCheckName = None
142 self.UpdateCheckData = None
143 self.CurrentVersion = ''
144 self.CurrentVersionCode = None
145 self.NoSourceSince = ''
148 self.metadatapath = None
152 self.lastupdated = None
153 self._modified = set()
156 def field_to_attr(cls, f):
158 Translates human-readable field names to attribute names, e.g.
159 'Auto Name' to 'AutoName'
161 return f.replace(' ', '')
164 def attr_to_field(cls, k):
166 Translates attribute names to human-readable field names, e.g.
167 'AutoName' to 'Auto Name'
171 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
174 def field_dict(self):
176 Constructs an old-fashioned dict with the human-readable field
177 names. Should only be used for tests.
180 for k, v in self.__dict__.items():
184 b = {k: v for k, v in build.__dict__.items() if not k.startswith('_')}
185 d['builds'].append(b)
186 elif not k.startswith('_'):
187 f = App.attr_to_field(k)
191 def get_field(self, f):
192 """Gets the value associated to a field name, e.g. 'Auto Name'"""
193 if f not in app_fields:
194 warn_or_exception('Unrecognised app field: ' + f)
195 k = App.field_to_attr(f)
196 return getattr(self, k)
198 def set_field(self, f, v):
199 """Sets the value associated to a field name, e.g. 'Auto Name'"""
200 if f not in app_fields:
201 warn_or_exception('Unrecognised app field: ' + f)
202 k = App.field_to_attr(f)
204 self._modified.add(k)
206 def append_field(self, f, v):
207 """Appends to the value associated to a field name, e.g. 'Auto Name'"""
208 if f not in app_fields:
209 warn_or_exception('Unrecognised app field: ' + f)
210 k = App.field_to_attr(f)
211 if k not in self.__dict__:
212 self.__dict__[k] = [v]
214 self.__dict__[k].append(v)
216 def update_fields(self, d):
217 '''Like dict.update(), but using human-readable field names'''
218 for f, v in d.items():
222 build.update_flags(b)
223 self.builds.append(build)
228 '''Like dict.update()'''
229 for k, v in d.__dict__.items():
235 del(b.__dict__['_modified'])
236 build.update_flags(b.__dict__)
237 self.builds.append(build)
240 self._modified.add(k)
242 def get_last_build(self):
243 if len(self.builds) > 0:
244 return self.builds[-1]
260 'Description': TYPE_MULTILINE,
261 'Maintainer Notes': TYPE_MULTILINE,
262 'Categories': TYPE_LIST,
263 'AntiFeatures': TYPE_LIST,
264 'Build Version': TYPE_BUILD,
265 'Build': TYPE_BUILD_V2,
266 'Use Built': TYPE_OBSOLETE,
271 if name in fieldtypes:
272 return fieldtypes[name]
276 # In the order in which they are laid out on files
277 build_flags_order = [
310 build_flags = set(build_flags_order + ['version', 'vercode'])
319 self.submodules = False
327 self.oldsdkloc = False
329 self.forceversion = False
330 self.forcevercode = False
341 self.preassemble = []
342 self.gradleprops = []
343 self.antcommands = []
344 self.novcheck = False
346 self._modified = set()
348 def get_flag(self, f):
349 if f not in build_flags:
350 warn_or_exception('Unrecognised build flag: ' + f)
351 return getattr(self, f)
353 def set_flag(self, f, v):
354 if f == 'versionName':
356 if f == 'versionCode':
358 if f not in build_flags:
359 warn_or_exception('Unrecognised build flag: ' + f)
361 self._modified.add(f)
363 def append_flag(self, f, v):
364 if f not in build_flags:
365 warn_or_exception('Unrecognised build flag: ' + f)
366 if f not in self.__dict__:
367 self.__dict__[f] = [v]
369 self.__dict__[f].append(v)
371 def build_method(self):
372 for f in ['maven', 'gradle', 'kivy']:
379 # like build_method, but prioritize output=
380 def output_method(self):
383 for f in ['maven', 'gradle', 'kivy']:
391 version = 'r12b' # falls back to latest
392 paths = fdroidserver.common.config['ndk_paths']
393 if version not in paths:
395 return paths[version]
397 def update_flags(self, d):
398 for f, v in d.items():
403 'extlibs': TYPE_LIST,
404 'srclibs': TYPE_LIST,
407 'buildjni': TYPE_LIST,
408 'preassemble': TYPE_LIST,
410 'scanignore': TYPE_LIST,
411 'scandelete': TYPE_LIST,
413 'antcommands': TYPE_LIST,
414 'gradleprops': TYPE_LIST,
416 'prebuild': TYPE_SCRIPT,
417 'build': TYPE_SCRIPT,
418 'submodules': TYPE_BOOL,
419 'oldsdkloc': TYPE_BOOL,
420 'forceversion': TYPE_BOOL,
421 'forcevercode': TYPE_BOOL,
422 'novcheck': TYPE_BOOL,
427 if name in flagtypes:
428 return flagtypes[name]
432 class FieldValidator():
434 Designates App metadata field types and checks that it matches
436 'name' - The long name of the field type
437 'matching' - List of possible values or regex expression
438 'sep' - Separator to use if value may be a list
439 'fields' - Metadata fields (Field:Value) of this type
442 def __init__(self, name, matching, fields):
444 self.matching = matching
445 self.compiled = re.compile(matching)
448 def check(self, v, appid):
456 if not self.compiled.match(v):
457 warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
458 % (v, self.name, appid, self.matching))
461 # Generic value types
463 FieldValidator("Hexadecimal",
467 FieldValidator("HTTP link",
469 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
471 FieldValidator("Email",
472 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
475 FieldValidator("Bitcoin address",
476 r'^[a-zA-Z0-9]{27,34}$',
479 FieldValidator("Litecoin address",
480 r'^L[a-zA-Z0-9]{33}$',
483 FieldValidator("Repo Type",
484 r'^(git|git-svn|svn|hg|bzr|srclib)$',
487 FieldValidator("Binaries",
491 FieldValidator("Archive Policy",
492 r'^[0-9]+ versions$',
495 FieldValidator("Anti-Feature",
496 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln)$',
499 FieldValidator("Auto Update Mode",
500 r"^(Version .+|None)$",
503 FieldValidator("Update Check Mode",
504 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
509 # Check an app's metadata information for integrity errors
510 def check_metadata(app):
513 if k not in app._modified:
515 v.check(app.__dict__[k], app.id)
518 # Formatter for descriptions. Create an instance, and call parseline() with
519 # each line of the description source from the metadata. At the end, call
520 # end() and then text_txt and text_html will contain the result.
521 class DescriptionFormatter:
528 def __init__(self, linkres):
531 self.state = self.stNONE
532 self.laststate = self.stNONE
535 self.html = io.StringIO()
536 self.text = io.StringIO()
538 self.linkResolver = None
539 self.linkResolver = linkres
541 def endcur(self, notstates=None):
542 if notstates and self.state in notstates:
544 if self.state == self.stPARA:
546 elif self.state == self.stUL:
548 elif self.state == self.stOL:
552 self.laststate = self.state
553 self.state = self.stNONE
554 whole_para = ' '.join(self.para_lines)
555 self.addtext(whole_para)
556 wrapped = textwrap.fill(whole_para, 80,
557 break_long_words=False,
558 break_on_hyphens=False)
559 self.text.write(wrapped)
560 self.html.write('</p>')
561 del self.para_lines[:]
564 self.html.write('</ul>')
565 self.laststate = self.state
566 self.state = self.stNONE
569 self.html.write('</ol>')
570 self.laststate = self.state
571 self.state = self.stNONE
573 def formatted(self, txt, html):
576 txt = cgi.escape(txt)
578 index = txt.find("''")
583 if txt.startswith("'''"):
589 self.bold = not self.bold
597 self.ital = not self.ital
600 def linkify(self, txt):
604 index = txt.find("[")
606 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
607 res_plain += self.formatted(txt[:index], False)
608 res_html += self.formatted(txt[:index], True)
610 if txt.startswith("[["):
611 index = txt.find("]]")
613 warn_or_exception("Unterminated ]]")
615 if self.linkResolver:
616 url, urltext = self.linkResolver(url)
619 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
621 txt = txt[index + 2:]
623 index = txt.find("]")
625 warn_or_exception("Unterminated ]")
627 index2 = url.find(' ')
631 urltxt = url[index2 + 1:]
634 warn_or_exception("Url title is just the URL - use [url]")
635 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
638 res_plain += ' (' + url + ')'
639 txt = txt[index + 1:]
641 def addtext(self, txt):
642 p, h = self.linkify(txt)
645 def parseline(self, line):
648 elif line.startswith('* '):
649 self.endcur([self.stUL])
650 if self.state != self.stUL:
651 self.html.write('<ul>')
652 self.state = self.stUL
653 if self.laststate != self.stNONE:
654 self.text.write('\n\n')
656 self.text.write('\n')
657 self.text.write(line)
658 self.html.write('<li>')
659 self.addtext(line[1:])
660 self.html.write('</li>')
661 elif line.startswith('# '):
662 self.endcur([self.stOL])
663 if self.state != self.stOL:
664 self.html.write('<ol>')
665 self.state = self.stOL
666 if self.laststate != self.stNONE:
667 self.text.write('\n\n')
669 self.text.write('\n')
670 self.text.write(line)
671 self.html.write('<li>')
672 self.addtext(line[1:])
673 self.html.write('</li>')
675 self.para_lines.append(line)
676 self.endcur([self.stPARA])
677 if self.state == self.stNONE:
678 self.state = self.stPARA
679 if self.laststate != self.stNONE:
680 self.text.write('\n\n')
681 self.html.write('<p>')
685 self.text_txt = self.text.getvalue()
686 self.text_html = self.html.getvalue()
691 # Parse multiple lines of description as written in a metadata file, returning
692 # a single string in text format and wrapped to 80 columns.
693 def description_txt(s):
694 ps = DescriptionFormatter(None)
695 for line in s.splitlines():
701 # Parse multiple lines of description as written in a metadata file, returning
702 # a single string in wiki format. Used for the Maintainer Notes field as well,
703 # because it's the same format.
704 def description_wiki(s):
708 # Parse multiple lines of description as written in a metadata file, returning
709 # a single string in HTML format.
710 def description_html(s, linkres):
711 ps = DescriptionFormatter(linkres)
712 for line in s.splitlines():
718 def parse_srclib(metadatapath):
722 # Defaults for fields that come from metadata
723 thisinfo['Repo Type'] = ''
724 thisinfo['Repo'] = ''
725 thisinfo['Subdir'] = None
726 thisinfo['Prepare'] = None
728 if not os.path.exists(metadatapath):
731 metafile = open(metadatapath, "r", encoding='utf-8')
734 for line in metafile:
736 line = line.rstrip('\r\n')
737 if not line or line.startswith("#"):
741 f, v = line.split(':', 1)
743 warn_or_exception("Invalid metadata in %s:%d" % (line, n))
746 thisinfo[f] = v.split(',')
756 """Read all srclib metadata.
758 The information read will be accessible as metadata.srclibs, which is a
759 dictionary, keyed on srclib name, with the values each being a dictionary
760 in the same format as that returned by the parse_srclib function.
762 A MetaDataException is raised if there are any problems with the srclib
767 # They were already loaded
768 if srclibs is not None:
774 if not os.path.exists(srcdir):
777 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
778 srclibname = os.path.basename(metadatapath[:-4])
779 srclibs[srclibname] = parse_srclib(metadatapath)
782 def read_metadata(xref=True, check_vcs=[]):
784 Read all metadata. Returns a list of 'app' objects (which are dictionaries as
785 returned by the parse_txt_metadata function.
787 check_vcs is the list of packageNames to check for .fdroid.yml in source
790 # Always read the srclibs before the apps, since they can use a srlib as
791 # their source repository.
796 for basedir in ('metadata', 'tmp'):
797 if not os.path.exists(basedir):
800 # If there are multiple metadata files for a single appid, then the first
801 # file that is parsed wins over all the others, and the rest throw an
802 # exception. So the original .txt format is parsed first, at least until
803 # newer formats stabilize.
805 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
806 + glob.glob(os.path.join('metadata', '*.json'))
807 + glob.glob(os.path.join('metadata', '*.xml'))
808 + glob.glob(os.path.join('metadata', '*.yml'))
809 + glob.glob('.fdroid.json')
810 + glob.glob('.fdroid.xml')
811 + glob.glob('.fdroid.yml')):
812 packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
813 if packageName in apps:
814 warn_or_exception("Found multiple metadata files for " + packageName)
815 app = parse_metadata(metadatapath, packageName in check_vcs)
820 # Parse all descriptions at load time, just to ensure cross-referencing
821 # errors are caught early rather than when they hit the build server.
824 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
825 warn_or_exception("Cannot resolve app id " + appid)
827 for appid, app in apps.items():
829 description_html(app.Description, linkres)
830 except MetaDataException as e:
831 warn_or_exception("Problem with description of " + appid +
837 # Port legacy ';' separators
838 list_sep = re.compile(r'[,;]')
841 def split_list_values(s):
843 for v in re.split(list_sep, s):
853 def get_default_app_info(metadatapath=None):
854 if metadatapath is None:
857 appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
859 if appid == '.fdroid': # we have local metadata in the app's source
860 if os.path.exists('AndroidManifest.xml'):
861 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
863 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
864 for root, dirs, files in os.walk(os.getcwd()):
865 if 'build.gradle' in files:
866 p = os.path.join(root, 'build.gradle')
867 with open(p, 'rb') as f:
869 m = pattern.search(data)
871 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
872 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
874 if manifestroot is None:
875 warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
876 appid = manifestroot.attrib['package']
879 app.metadatapath = metadatapath
880 if appid is not None:
886 def sorted_builds(builds):
887 return sorted(builds, key=lambda build: int(build.vercode))
890 esc_newlines = re.compile(r'\\( |\n)')
893 # This function uses __dict__ to be faster
894 def post_metadata_parse(app):
896 for k in app._modified:
898 if type(v) in (float, int):
899 app.__dict__[k] = str(v)
901 for build in app.builds:
902 for k in build._modified:
903 v = build.__dict__[k]
904 if type(v) in (float, int):
905 build.__dict__[k] = str(v)
909 if ftype == TYPE_SCRIPT:
910 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
911 elif ftype == TYPE_BOOL:
912 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
913 if isinstance(v, str):
914 build.__dict__[k] = _decode_bool(v)
915 elif ftype == TYPE_STRING:
916 if isinstance(v, bool) and v:
917 build.__dict__[k] = 'yes'
918 elif ftype == TYPE_LIST:
919 if isinstance(v, bool) and v:
920 build.__dict__[k] = ['yes']
921 elif isinstance(v, str):
922 build.__dict__[k] = [v]
924 if not app.Description:
925 app.Description = 'No description available'
927 app.builds = sorted_builds(app.builds)
930 # Parse metadata for a single application.
932 # 'metadatapath' - the filename to read. The package id for the application comes
933 # from this filename. Pass None to get a blank entry.
935 # Returns a dictionary containing all the details of the application. There are
936 # two major kinds of information in the dictionary. Keys beginning with capital
937 # letters correspond directory to identically named keys in the metadata file.
938 # Keys beginning with lower case letters are generated in one way or another,
939 # and are not found verbatim in the metadata.
941 # Known keys not originating from the metadata are:
943 # 'builds' - a list of dictionaries containing build information
944 # for each defined build
945 # 'comments' - a list of comments from the metadata file. Each is
946 # a list of the form [field, comment] where field is
947 # the name of the field it preceded in the metadata
948 # file. Where field is None, the comment goes at the
949 # end of the file. Alternatively, 'build:version' is
950 # for a comment before a particular build version.
951 # 'descriptionlines' - original lines of description as formatted in the
956 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
957 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
961 if bool_true.match(s):
963 if bool_false.match(s):
965 warn_or_exception("Invalid bool '%s'" % s)
968 def parse_metadata(metadatapath, check_vcs=False):
969 '''parse metadata file, optionally checking the git repo for metadata first'''
971 _, ext = fdroidserver.common.get_extension(metadatapath)
972 accepted = fdroidserver.common.config['accepted_formats']
973 if ext not in accepted:
974 warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
975 metadatapath, ', '.join(accepted)))
978 app.metadatapath = metadatapath
979 name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
980 if name == '.fdroid':
985 with open(metadatapath, 'r', encoding='utf-8') as mf:
987 parse_txt_metadata(mf, app)
989 parse_json_metadata(mf, app)
991 parse_xml_metadata(mf, app)
993 parse_yaml_metadata(mf, app)
995 warn_or_exception('Unknown metadata format: %s' % metadatapath)
997 if check_vcs and app.Repo:
998 build_dir = fdroidserver.common.get_build_dir(app)
999 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
1000 if not os.path.isfile(metadata_in_repo):
1001 vcs, build_dir = fdroidserver.common.setup_vcs(app)
1002 vcs.gotorevision('HEAD') # HEAD since we can't know where else to go
1003 if os.path.isfile(metadata_in_repo):
1004 logging.debug('Including metadata from ' + metadata_in_repo)
1005 app.update(parse_metadata(metadata_in_repo))
1007 post_metadata_parse(app)
1011 build = app.builds[-1]
1013 root_dir = build.subdir
1016 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
1017 _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
1022 def parse_json_metadata(mf, app):
1024 # fdroid metadata is only strings and booleans, no floats or ints.
1025 # TODO create schema using https://pypi.python.org/pypi/jsonschema
1026 jsoninfo = json.load(mf, parse_int=lambda s: s,
1027 parse_float=lambda s: s)
1028 app.update_fields(jsoninfo)
1029 for f in ['Description', 'Maintainer Notes']:
1030 v = app.get_field(f)
1031 app.set_field(f, '\n'.join(v))
1035 def parse_xml_metadata(mf, app):
1037 tree = ElementTree.ElementTree(file=mf)
1038 root = tree.getroot()
1040 if root.tag != 'resources':
1041 warn_or_exception('resources file does not have root element <resources/>')
1044 if child.tag != 'builds':
1045 # builds does not have name="" attrib
1046 name = child.attrib['name']
1048 if child.tag == 'string':
1049 app.set_field(name, child.text)
1050 elif child.tag == 'string-array':
1052 app.append_field(name, item.text)
1053 elif child.tag == 'builds':
1057 build.set_flag(key.tag, key.text)
1058 app.builds.append(build)
1060 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1061 if not isinstance(app.RequiresRoot, bool):
1062 app.RequiresRoot = app.RequiresRoot == 'true'
1067 def parse_yaml_metadata(mf, app):
1069 yamlinfo = yaml.load(mf, Loader=YamlLoader)
1070 app.update_fields(yamlinfo)
1074 build_line_sep = re.compile(r'(?<!\\),')
1075 build_cont = re.compile(r'^[ \t]')
1078 def parse_txt_metadata(mf, app):
1082 def add_buildflag(p, build):
1084 warn_or_exception("Empty build flag at {1}"
1085 .format(buildlines[0], linedesc))
1086 bv = p.split('=', 1)
1088 warn_or_exception("Invalid build flag at {0} in {1}"
1089 .format(buildlines[0], linedesc))
1095 pv = split_list_values(pv)
1096 build.set_flag(pk, pv)
1097 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1098 build.set_flag(pk, pv)
1099 elif t == TYPE_BOOL:
1100 build.set_flag(pk, _decode_bool(pv))
1102 def parse_buildline(lines):
1104 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1106 warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1108 build.version = parts[0]
1109 build.vercode = parts[1]
1110 check_versionCode(build.vercode)
1112 if parts[2].startswith('!'):
1113 # For backwards compatibility, handle old-style disabling,
1114 # including attempting to extract the commit from the message
1115 build.disable = parts[2][1:]
1116 commit = 'unknown - see disabled'
1117 index = parts[2].rfind('at ')
1119 commit = parts[2][index + 3:]
1120 if commit.endswith(')'):
1121 commit = commit[:-1]
1122 build.commit = commit
1124 build.commit = parts[2]
1126 add_buildflag(p, build)
1130 def check_versionCode(versionCode):
1134 warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1136 def add_comments(key):
1139 app.comments[key] = list(curcomments)
1144 multiline_lines = []
1152 linedesc = "%s:%d" % (mf.name, c)
1153 line = line.rstrip('\r\n')
1155 if build_cont.match(line):
1156 if line.endswith('\\'):
1157 buildlines.append(line[:-1].lstrip())
1159 buildlines.append(line.lstrip())
1160 bl = ''.join(buildlines)
1161 add_buildflag(bl, build)
1164 if not build.commit and not build.disable:
1165 warn_or_exception("No commit specified for {0} in {1}"
1166 .format(build.version, linedesc))
1168 app.builds.append(build)
1169 add_comments('build:' + build.vercode)
1175 if line.startswith("#"):
1176 curcomments.append(line[1:].strip())
1179 f, v = line.split(':', 1)
1181 warn_or_exception("Invalid metadata in " + linedesc)
1183 # Translate obsolete fields...
1184 if f == 'Market Version':
1185 f = 'Current Version'
1186 if f == 'Market Version Code':
1187 f = 'Current Version Code'
1189 ftype = fieldtype(f)
1190 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1192 if ftype == TYPE_MULTILINE:
1195 warn_or_exception("Unexpected text on same line as "
1196 + f + " in " + linedesc)
1197 elif ftype == TYPE_STRING:
1199 elif ftype == TYPE_LIST:
1200 app.set_field(f, split_list_values(v))
1201 elif ftype == TYPE_BUILD:
1202 if v.endswith("\\"):
1205 buildlines.append(v[:-1])
1207 build = parse_buildline([v])
1208 app.builds.append(build)
1209 add_comments('build:' + app.builds[-1].vercode)
1210 elif ftype == TYPE_BUILD_V2:
1213 warn_or_exception('Build should have comma-separated',
1214 'version and vercode,',
1215 'not "{0}", in {1}'.format(v, linedesc))
1217 build.version = vv[0]
1218 build.vercode = vv[1]
1219 check_versionCode(build.vercode)
1220 if build.vercode in vc_seen:
1221 warn_or_exception('Duplicate build recipe found for vercode %s in %s'
1222 % (build.vercode, linedesc))
1223 vc_seen.add(build.vercode)
1226 elif ftype == TYPE_OBSOLETE:
1227 pass # Just throw it away!
1229 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1230 elif mode == 1: # Multiline field
1233 app.set_field(f, '\n'.join(multiline_lines))
1234 del multiline_lines[:]
1236 multiline_lines.append(line)
1237 elif mode == 2: # Line continuation mode in Build Version
1238 if line.endswith("\\"):
1239 buildlines.append(line[:-1])
1241 buildlines.append(line)
1242 build = parse_buildline(buildlines)
1243 app.builds.append(build)
1244 add_comments('build:' + app.builds[-1].vercode)
1248 # Mode at end of file should always be 0
1250 warn_or_exception(f + " not terminated in " + mf.name)
1252 warn_or_exception("Unterminated continuation in " + mf.name)
1254 warn_or_exception("Unterminated build in " + mf.name)
1259 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1261 def w_comments(key):
1262 if key not in app.comments:
1264 for line in app.comments[key]:
1267 def w_field_always(f, v=None):
1269 v = app.get_field(f)
1273 def w_field_nonempty(f, v=None):
1275 v = app.get_field(f)
1280 w_field_nonempty('Disabled')
1281 w_field_nonempty('AntiFeatures')
1282 w_field_nonempty('Provides')
1283 w_field_always('Categories')
1284 w_field_always('License')
1285 w_field_nonempty('Author Name')
1286 w_field_nonempty('Author Email')
1287 w_field_always('Web Site')
1288 w_field_always('Source Code')
1289 w_field_always('Issue Tracker')
1290 w_field_nonempty('Changelog')
1291 w_field_nonempty('Donate')
1292 w_field_nonempty('FlattrID')
1293 w_field_nonempty('Bitcoin')
1294 w_field_nonempty('Litecoin')
1296 w_field_nonempty('Name')
1297 w_field_nonempty('Auto Name')
1298 w_field_always('Summary')
1299 w_field_always('Description', description_txt(app.Description))
1301 if app.RequiresRoot:
1302 w_field_always('Requires Root', 'yes')
1305 w_field_always('Repo Type')
1306 w_field_always('Repo')
1308 w_field_always('Binaries')
1311 for build in app.builds:
1313 if build.version == "Ignore":
1316 w_comments('build:' + build.vercode)
1320 if app.MaintainerNotes:
1321 w_field_always('Maintainer Notes', app.MaintainerNotes)
1324 w_field_nonempty('Archive Policy')
1325 w_field_always('Auto Update Mode')
1326 w_field_always('Update Check Mode')
1327 w_field_nonempty('Update Check Ignore')
1328 w_field_nonempty('Vercode Operation')
1329 w_field_nonempty('Update Check Name')
1330 w_field_nonempty('Update Check Data')
1331 if app.CurrentVersion:
1332 w_field_always('Current Version')
1333 w_field_always('Current Version Code')
1334 if app.NoSourceSince:
1336 w_field_always('No Source Since')
1340 # Write a metadata file in txt format.
1342 # 'mf' - Writer interface (file, StringIO, ...)
1343 # 'app' - The app data
1344 def write_txt(mf, app):
1346 def w_comment(line):
1347 mf.write("# %s\n" % line)
1353 elif t == TYPE_MULTILINE:
1354 v = '\n' + v + '\n.'
1355 mf.write("%s:%s\n" % (f, v))
1358 mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1360 for f in build_flags_order:
1361 v = build.get_flag(f)
1366 mf.write(' %s=' % f)
1367 if t == TYPE_STRING:
1369 elif t == TYPE_BOOL:
1371 elif t == TYPE_SCRIPT:
1373 for s in v.split(' && '):
1377 mf.write(' && \\\n ')
1379 elif t == TYPE_LIST:
1380 mf.write(','.join(v))
1384 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1387 def write_yaml(mf, app):
1389 def w_comment(line):
1390 mf.write("# %s\n" % line)
1395 if any(c in v for c in [': ', '%', '@', '*']):
1396 return "'" + v.replace("'", "''") + "'"
1399 def w_field(f, v, prefix='', t=None):
1406 v += prefix + ' - ' + escape(e) + '\n'
1407 elif t == TYPE_MULTILINE:
1409 for l in v.splitlines():
1411 v += prefix + ' ' + l + '\n'
1414 elif t == TYPE_BOOL:
1416 elif t == TYPE_SCRIPT:
1417 cmds = [s + '&& \\' for s in v.split('&& ')]
1419 cmds[-1] = cmds[-1][:-len('&& \\')]
1420 w_field(f, cmds, prefix, 'multiline')
1423 v = ' ' + escape(v) + '\n'
1436 mf.write("builds:\n")
1439 w_field('versionName', build.version, ' - ', TYPE_STRING)
1440 w_field('versionCode', build.vercode, ' ', TYPE_STRING)
1441 for f in build_flags_order:
1442 v = build.get_flag(f)
1446 w_field(f, v, ' ', flagtype(f))
1448 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1451 def write_metadata(metadatapath, app):
1452 _, ext = fdroidserver.common.get_extension(metadatapath)
1453 accepted = fdroidserver.common.config['accepted_formats']
1454 if ext not in accepted:
1455 warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1456 % (metadatapath, ', '.join(accepted)))
1458 with open(metadatapath, 'w', encoding='utf8') as mf:
1460 return write_txt(mf, app)
1462 return write_yaml(mf, app)
1463 warn_or_exception('Unknown metadata format: %s' % metadatapath)
1466 def add_metadata_arguments(parser):
1467 '''add common command line flags related to metadata processing'''
1468 parser.add_argument("-W", default='error',
1469 help="force errors to be warnings, or ignore")