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
46 class MetaDataException(Exception):
48 def __init__(self, value):
54 # To filter which ones should be written to the metadata files if
84 'Update Check Ignore',
89 'Current Version Code',
92 'comments', # For formats that don't do inline comments
93 'builds', # For formats that do builds as a list
101 self.AntiFeatures = []
103 self.Categories = ['None']
104 self.License = 'Unknown'
105 self.AuthorName = None
106 self.AuthorEmail = None
109 self.IssueTracker = ''
118 self.Description = ''
119 self.RequiresRoot = False
123 self.MaintainerNotes = ''
124 self.ArchivePolicy = None
125 self.AutoUpdateMode = 'None'
126 self.UpdateCheckMode = 'None'
127 self.UpdateCheckIgnore = None
128 self.VercodeOperation = None
129 self.UpdateCheckName = None
130 self.UpdateCheckData = None
131 self.CurrentVersion = ''
132 self.CurrentVersionCode = None
133 self.NoSourceSince = ''
136 self.metadatapath = None
140 self.lastupdated = None
141 self._modified = set()
143 # Translates human-readable field names to attribute names, e.g.
144 # 'Auto Name' to 'AutoName'
146 def field_to_attr(cls, f):
147 return f.replace(' ', '')
149 # Translates attribute names to human-readable field names, e.g.
150 # 'AutoName' to 'Auto Name'
152 def attr_to_field(cls, k):
155 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
158 # Constructs an old-fashioned dict with the human-readable field
159 # names. Should only be used for tests.
160 def field_dict(self):
162 for k, v in self.__dict__.items():
166 b = {k: v for k, v in build.__dict__.items() if not k.startswith('_')}
167 d['builds'].append(b)
168 elif not k.startswith('_'):
169 f = App.attr_to_field(k)
173 # Gets the value associated to a field name, e.g. 'Auto Name'
174 def get_field(self, f):
175 if f not in app_fields:
176 raise MetaDataException('Unrecognised app field: ' + f)
177 k = App.field_to_attr(f)
178 return getattr(self, k)
180 # Sets the value associated to a field name, e.g. 'Auto Name'
181 def set_field(self, f, v):
182 if f not in app_fields:
183 raise MetaDataException('Unrecognised app field: ' + f)
184 k = App.field_to_attr(f)
186 self._modified.add(k)
188 # Appends to the value associated to a field name, e.g. 'Auto Name'
189 def append_field(self, f, v):
190 if f not in app_fields:
191 raise MetaDataException('Unrecognised app field: ' + f)
192 k = App.field_to_attr(f)
193 if k not in self.__dict__:
194 self.__dict__[k] = [v]
196 self.__dict__[k].append(v)
198 # Like dict.update(), but using human-readable field names
199 def update_fields(self, d):
200 for f, v in d.items():
204 build.update_flags(b)
205 self.builds.append(build)
220 'Description': TYPE_MULTILINE,
221 'Maintainer Notes': TYPE_MULTILINE,
222 'Categories': TYPE_LIST,
223 'AntiFeatures': TYPE_LIST,
224 'Build Version': TYPE_BUILD,
225 'Build': TYPE_BUILD_V2,
226 'Use Built': TYPE_OBSOLETE,
231 if name in fieldtypes:
232 return fieldtypes[name]
236 # In the order in which they are laid out on files
237 build_flags_order = [
270 build_flags = set(build_flags_order + ['version', 'vercode'])
279 self.submodules = False
287 self.oldsdkloc = False
289 self.forceversion = False
290 self.forcevercode = False
301 self.preassemble = []
302 self.gradleprops = []
303 self.antcommands = []
304 self.novcheck = False
306 self._modified = set()
308 def get_flag(self, f):
309 if f not in build_flags:
310 raise MetaDataException('Unrecognised build flag: ' + f)
311 return getattr(self, f)
313 def set_flag(self, f, v):
314 if f == 'versionName':
316 if f == 'versionCode':
318 if f not in build_flags:
319 raise MetaDataException('Unrecognised build flag: ' + f)
321 self._modified.add(f)
323 def append_flag(self, f, v):
324 if f not in build_flags:
325 raise MetaDataException('Unrecognised build flag: ' + f)
326 if f not in self.__dict__:
327 self.__dict__[f] = [v]
329 self.__dict__[f].append(v)
331 def build_method(self):
332 for f in ['maven', 'gradle', 'kivy']:
339 # like build_method, but prioritize output=
340 def output_method(self):
343 for f in ['maven', 'gradle', 'kivy']:
351 version = 'r12b' # falls back to latest
352 paths = fdroidserver.common.config['ndk_paths']
353 if version not in paths:
355 return paths[version]
357 def update_flags(self, d):
358 for f, v in d.items():
362 'extlibs': TYPE_LIST,
363 'srclibs': TYPE_LIST,
366 'buildjni': TYPE_LIST,
367 'preassemble': TYPE_LIST,
369 'scanignore': TYPE_LIST,
370 'scandelete': TYPE_LIST,
372 'antcommands': TYPE_LIST,
373 'gradleprops': TYPE_LIST,
375 'prebuild': TYPE_SCRIPT,
376 'build': TYPE_SCRIPT,
377 'submodules': TYPE_BOOL,
378 'oldsdkloc': TYPE_BOOL,
379 'forceversion': TYPE_BOOL,
380 'forcevercode': TYPE_BOOL,
381 'novcheck': TYPE_BOOL,
386 if name in flagtypes:
387 return flagtypes[name]
391 # Designates a metadata field type and checks that it matches
393 # 'name' - The long name of the field type
394 # 'matching' - List of possible values or regex expression
395 # 'sep' - Separator to use if value may be a list
396 # 'fields' - Metadata fields (Field:Value) of this type
397 # 'flags' - Build flags (flag=value) of this type
399 class FieldValidator():
401 def __init__(self, name, matching, fields, flags):
403 self.matching = matching
404 self.compiled = re.compile(matching)
408 def check(self, v, appid):
416 if not self.compiled.match(v):
417 raise MetaDataException("'%s' is not a valid %s in %s. Regex pattern: %s"
418 % (v, self.name, appid, self.matching))
420 # Generic value types
422 FieldValidator("Integer",
427 FieldValidator("Hexadecimal",
432 FieldValidator("HTTP link",
434 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"], []),
436 FieldValidator("Email",
437 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
438 ["AuthorEmail"], []),
440 FieldValidator("Bitcoin address",
441 r'^[a-zA-Z0-9]{27,34}$',
445 FieldValidator("Litecoin address",
446 r'^L[a-zA-Z0-9]{33}$',
450 FieldValidator("Repo Type",
451 r'^(git|git-svn|svn|hg|bzr|srclib)$',
455 FieldValidator("Binaries",
460 FieldValidator("Archive Policy",
461 r'^[0-9]+ versions$',
465 FieldValidator("Anti-Feature",
466 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets)$',
470 FieldValidator("Auto Update Mode",
471 r"^(Version .+|None)$",
475 FieldValidator("Update Check Mode",
476 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
482 # Check an app's metadata information for integrity errors
483 def check_metadata(app):
486 if k not in app._modified:
488 v.check(app.__dict__[k], app.id)
489 for build in app.builds:
491 if k not in build._modified:
493 v.check(build.__dict__[k], app.id)
496 # Formatter for descriptions. Create an instance, and call parseline() with
497 # each line of the description source from the metadata. At the end, call
498 # end() and then text_txt and text_html will contain the result.
499 class DescriptionFormatter:
506 def __init__(self, linkres):
509 self.state = self.stNONE
510 self.laststate = self.stNONE
513 self.html = io.StringIO()
514 self.text = io.StringIO()
516 self.linkResolver = None
517 self.linkResolver = linkres
519 def endcur(self, notstates=None):
520 if notstates and self.state in notstates:
522 if self.state == self.stPARA:
524 elif self.state == self.stUL:
526 elif self.state == self.stOL:
530 self.laststate = self.state
531 self.state = self.stNONE
532 whole_para = ' '.join(self.para_lines)
533 self.addtext(whole_para)
534 wrapped = textwrap.fill(whole_para, 80,
535 break_long_words=False,
536 break_on_hyphens=False)
537 self.text.write(wrapped)
538 self.html.write('</p>')
539 del self.para_lines[:]
542 self.html.write('</ul>')
543 self.laststate = self.state
544 self.state = self.stNONE
547 self.html.write('</ol>')
548 self.laststate = self.state
549 self.state = self.stNONE
551 def formatted(self, txt, html):
554 txt = cgi.escape(txt)
556 index = txt.find("''")
561 if txt.startswith("'''"):
567 self.bold = not self.bold
575 self.ital = not self.ital
578 def linkify(self, txt):
582 index = txt.find("[")
584 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
585 res_plain += self.formatted(txt[:index], False)
586 res_html += self.formatted(txt[:index], True)
588 if txt.startswith("[["):
589 index = txt.find("]]")
591 raise MetaDataException("Unterminated ]]")
593 if self.linkResolver:
594 url, urltext = self.linkResolver(url)
597 res_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
599 txt = txt[index + 2:]
601 index = txt.find("]")
603 raise MetaDataException("Unterminated ]")
605 index2 = url.find(' ')
609 urltxt = url[index2 + 1:]
612 raise MetaDataException("Url title is just the URL - use [url]")
613 res_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
616 res_plain += ' (' + url + ')'
617 txt = txt[index + 1:]
619 def addtext(self, txt):
620 p, h = self.linkify(txt)
623 def parseline(self, line):
626 elif line.startswith('* '):
627 self.endcur([self.stUL])
628 if self.state != self.stUL:
629 self.html.write('<ul>')
630 self.state = self.stUL
631 if self.laststate != self.stNONE:
632 self.text.write('\n\n')
634 self.text.write('\n')
635 self.text.write(line)
636 self.html.write('<li>')
637 self.addtext(line[1:])
638 self.html.write('</li>')
639 elif line.startswith('# '):
640 self.endcur([self.stOL])
641 if self.state != self.stOL:
642 self.html.write('<ol>')
643 self.state = self.stOL
644 if self.laststate != self.stNONE:
645 self.text.write('\n\n')
647 self.text.write('\n')
648 self.text.write(line)
649 self.html.write('<li>')
650 self.addtext(line[1:])
651 self.html.write('</li>')
653 self.para_lines.append(line)
654 self.endcur([self.stPARA])
655 if self.state == self.stNONE:
656 self.state = self.stPARA
657 if self.laststate != self.stNONE:
658 self.text.write('\n\n')
659 self.html.write('<p>')
663 self.text_txt = self.text.getvalue()
664 self.text_html = self.html.getvalue()
669 # Parse multiple lines of description as written in a metadata file, returning
670 # a single string in text format and wrapped to 80 columns.
671 def description_txt(s):
672 ps = DescriptionFormatter(None)
673 for line in s.splitlines():
679 # Parse multiple lines of description as written in a metadata file, returning
680 # a single string in wiki format. Used for the Maintainer Notes field as well,
681 # because it's the same format.
682 def description_wiki(s):
686 # Parse multiple lines of description as written in a metadata file, returning
687 # a single string in HTML format.
688 def description_html(s, linkres):
689 ps = DescriptionFormatter(linkres)
690 for line in s.splitlines():
696 def parse_srclib(metadatapath):
700 # Defaults for fields that come from metadata
701 thisinfo['Repo Type'] = ''
702 thisinfo['Repo'] = ''
703 thisinfo['Subdir'] = None
704 thisinfo['Prepare'] = None
706 if not os.path.exists(metadatapath):
709 metafile = open(metadatapath, "r", encoding='utf-8')
712 for line in metafile:
714 line = line.rstrip('\r\n')
715 if not line or line.startswith("#"):
719 f, v = line.split(':', 1)
721 raise MetaDataException("Invalid metadata in %s:%d" % (line, n))
724 thisinfo[f] = v.split(',')
734 """Read all srclib metadata.
736 The information read will be accessible as metadata.srclibs, which is a
737 dictionary, keyed on srclib name, with the values each being a dictionary
738 in the same format as that returned by the parse_srclib function.
740 A MetaDataException is raised if there are any problems with the srclib
745 # They were already loaded
746 if srclibs is not None:
752 if not os.path.exists(srcdir):
755 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
756 srclibname = os.path.basename(metadatapath[:-4])
757 srclibs[srclibname] = parse_srclib(metadatapath)
760 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
761 # returned by the parse_txt_metadata function.
762 def read_metadata(xref=True):
764 # Always read the srclibs before the apps, since they can use a srlib as
765 # their source repository.
770 for basedir in ('metadata', 'tmp'):
771 if not os.path.exists(basedir):
774 # If there are multiple metadata files for a single appid, then the first
775 # file that is parsed wins over all the others, and the rest throw an
776 # exception. So the original .txt format is parsed first, at least until
777 # newer formats stabilize.
779 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
780 + glob.glob(os.path.join('metadata', '*.json'))
781 + glob.glob(os.path.join('metadata', '*.xml'))
782 + glob.glob(os.path.join('metadata', '*.yml'))
783 + glob.glob('.fdroid.json')
784 + glob.glob('.fdroid.xml')
785 + glob.glob('.fdroid.yml')):
786 packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
787 if packageName in apps:
788 raise MetaDataException("Found multiple metadata files for " + packageName)
789 app = parse_metadata(metadatapath)
794 # Parse all descriptions at load time, just to ensure cross-referencing
795 # errors are caught early rather than when they hit the build server.
798 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
799 raise MetaDataException("Cannot resolve app id " + appid)
801 for appid, app in apps.items():
803 description_html(app.Description, linkres)
804 except MetaDataException as e:
805 raise MetaDataException("Problem with description of " + appid +
810 # Port legacy ';' separators
811 list_sep = re.compile(r'[,;]')
814 def split_list_values(s):
816 for v in re.split(list_sep, s):
826 def get_default_app_info(metadatapath=None):
827 if metadatapath is None:
830 appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
832 if appid == '.fdroid': # we have local metadata in the app's source
833 if os.path.exists('AndroidManifest.xml'):
834 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
836 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
837 for root, dirs, files in os.walk(os.getcwd()):
838 if 'build.gradle' in files:
839 p = os.path.join(root, 'build.gradle')
840 with open(p, 'rb') as f:
842 m = pattern.search(data)
844 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
845 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
847 if manifestroot is None:
848 raise MetaDataException("Cannot find a packageName for {0}!".format(metadatapath))
849 appid = manifestroot.attrib['package']
852 app.metadatapath = metadatapath
853 if appid is not None:
859 def sorted_builds(builds):
860 return sorted(builds, key=lambda build: int(build.vercode))
863 esc_newlines = re.compile(r'\\( |\n)')
866 # This function uses __dict__ to be faster
867 def post_metadata_parse(app):
869 for k in app._modified:
871 if type(v) in (float, int):
872 app.__dict__[k] = str(v)
874 for build in app.builds:
875 for k in build._modified:
876 v = build.__dict__[k]
877 if type(v) in (float, int):
878 build.__dict__[k] = str(v)
882 if ftype == TYPE_SCRIPT:
883 build.__dict__[k] = re.sub(esc_newlines, '', v).lstrip().rstrip()
884 elif ftype == TYPE_BOOL:
885 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
886 if isinstance(v, str):
887 build.__dict__[k] = _decode_bool(v)
888 elif ftype == TYPE_STRING:
889 if isinstance(v, bool) and v:
890 build.__dict__[k] = 'yes'
892 if not app.Description:
893 app.Description = 'No description available'
895 app.builds = sorted_builds(app.builds)
898 # Parse metadata for a single application.
900 # 'metadatapath' - the filename to read. The package id for the application comes
901 # from this filename. Pass None to get a blank entry.
903 # Returns a dictionary containing all the details of the application. There are
904 # two major kinds of information in the dictionary. Keys beginning with capital
905 # letters correspond directory to identically named keys in the metadata file.
906 # Keys beginning with lower case letters are generated in one way or another,
907 # and are not found verbatim in the metadata.
909 # Known keys not originating from the metadata are:
911 # 'builds' - a list of dictionaries containing build information
912 # for each defined build
913 # 'comments' - a list of comments from the metadata file. Each is
914 # a list of the form [field, comment] where field is
915 # the name of the field it preceded in the metadata
916 # file. Where field is None, the comment goes at the
917 # end of the file. Alternatively, 'build:version' is
918 # for a comment before a particular build version.
919 # 'descriptionlines' - original lines of description as formatted in the
924 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
925 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
929 if bool_true.match(s):
931 if bool_false.match(s):
933 raise MetaDataException("Invalid bool '%s'" % s)
936 def parse_metadata(metadatapath):
937 _, ext = fdroidserver.common.get_extension(metadatapath)
938 accepted = fdroidserver.common.config['accepted_formats']
939 if ext not in accepted:
940 raise MetaDataException('"%s" is not an accepted format, convert to: %s' % (
941 metadatapath, ', '.join(accepted)))
944 app.metadatapath = metadatapath
945 app.id, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
947 with open(metadatapath, 'r', encoding='utf-8') as mf:
949 parse_txt_metadata(mf, app)
951 parse_json_metadata(mf, app)
953 parse_xml_metadata(mf, app)
955 parse_yaml_metadata(mf, app)
957 raise MetaDataException('Unknown metadata format: %s' % metadatapath)
959 post_metadata_parse(app)
963 def parse_json_metadata(mf, app):
965 # fdroid metadata is only strings and booleans, no floats or ints.
966 # TODO create schema using https://pypi.python.org/pypi/jsonschema
967 jsoninfo = json.load(mf, parse_int=lambda s: s,
968 parse_float=lambda s: s)
969 app.update_fields(jsoninfo)
970 for f in ['Description', 'Maintainer Notes']:
972 app.set_field(f, '\n'.join(v))
976 def parse_xml_metadata(mf, app):
978 tree = ElementTree.ElementTree(file=mf)
979 root = tree.getroot()
981 if root.tag != 'resources':
982 raise MetaDataException('resources file does not have root element <resources/>')
985 if child.tag != 'builds':
986 # builds does not have name="" attrib
987 name = child.attrib['name']
989 if child.tag == 'string':
990 app.set_field(name, child.text)
991 elif child.tag == 'string-array':
993 app.append_field(name, item.text)
994 elif child.tag == 'builds':
998 build.set_flag(key.tag, key.text)
999 app.builds.append(build)
1001 # TODO handle this using <xsd:element type="xsd:boolean> in a schema
1002 if not isinstance(app.RequiresRoot, bool):
1003 app.RequiresRoot = app.RequiresRoot == 'true'
1008 def parse_yaml_metadata(mf, app):
1010 yamlinfo = yaml.load(mf, Loader=YamlLoader)
1011 app.update_fields(yamlinfo)
1015 build_line_sep = re.compile(r'(?<!\\),')
1016 build_cont = re.compile(r'^[ \t]')
1019 def parse_txt_metadata(mf, app):
1023 def add_buildflag(p, build):
1025 raise MetaDataException("Empty build flag at {1}"
1026 .format(buildlines[0], linedesc))
1027 bv = p.split('=', 1)
1029 raise MetaDataException("Invalid build flag at {0} in {1}"
1030 .format(buildlines[0], linedesc))
1036 pv = split_list_values(pv)
1037 build.set_flag(pk, pv)
1038 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1039 build.set_flag(pk, pv)
1040 elif t == TYPE_BOOL:
1041 build.set_flag(pk, _decode_bool(pv))
1043 def parse_buildline(lines):
1045 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1047 raise MetaDataException("Invalid build format: " + v + " in " + mf.name)
1049 build.version = parts[0]
1050 build.vercode = parts[1]
1051 if parts[2].startswith('!'):
1052 # For backwards compatibility, handle old-style disabling,
1053 # including attempting to extract the commit from the message
1054 build.disable = parts[2][1:]
1055 commit = 'unknown - see disabled'
1056 index = parts[2].rfind('at ')
1058 commit = parts[2][index + 3:]
1059 if commit.endswith(')'):
1060 commit = commit[:-1]
1061 build.commit = commit
1063 build.commit = parts[2]
1065 add_buildflag(p, build)
1069 def add_comments(key):
1072 app.comments[key] = list(curcomments)
1077 multiline_lines = []
1085 linedesc = "%s:%d" % (mf.name, c)
1086 line = line.rstrip('\r\n')
1088 if build_cont.match(line):
1089 if line.endswith('\\'):
1090 buildlines.append(line[:-1].lstrip())
1092 buildlines.append(line.lstrip())
1093 bl = ''.join(buildlines)
1094 add_buildflag(bl, build)
1097 if not build.commit and not build.disable:
1098 raise MetaDataException("No commit specified for {0} in {1}"
1099 .format(build.version, linedesc))
1101 app.builds.append(build)
1102 add_comments('build:' + build.vercode)
1108 if line.startswith("#"):
1109 curcomments.append(line[1:].strip())
1112 f, v = line.split(':', 1)
1114 raise MetaDataException("Invalid metadata in " + linedesc)
1116 # Translate obsolete fields...
1117 if f == 'Market Version':
1118 f = 'Current Version'
1119 if f == 'Market Version Code':
1120 f = 'Current Version Code'
1122 ftype = fieldtype(f)
1123 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1125 if ftype == TYPE_MULTILINE:
1128 raise MetaDataException("Unexpected text on same line as " + f + " in " + linedesc)
1129 elif ftype == TYPE_STRING:
1131 elif ftype == TYPE_LIST:
1132 app.set_field(f, split_list_values(v))
1133 elif ftype == TYPE_BUILD:
1134 if v.endswith("\\"):
1137 buildlines.append(v[:-1])
1139 build = parse_buildline([v])
1140 app.builds.append(build)
1141 add_comments('build:' + app.builds[-1].vercode)
1142 elif ftype == TYPE_BUILD_V2:
1145 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'
1146 .format(v, linedesc))
1148 build.version = vv[0]
1149 build.vercode = vv[1]
1150 if build.vercode in vc_seen:
1151 raise MetaDataException('Duplicate build recipe found for vercode %s in %s' % (
1152 build.vercode, linedesc))
1153 vc_seen.add(build.vercode)
1156 elif ftype == TYPE_OBSOLETE:
1157 pass # Just throw it away!
1159 raise MetaDataException("Unrecognised field '" + f + "' in " + linedesc)
1160 elif mode == 1: # Multiline field
1163 app.set_field(f, '\n'.join(multiline_lines))
1164 del multiline_lines[:]
1166 multiline_lines.append(line)
1167 elif mode == 2: # Line continuation mode in Build Version
1168 if line.endswith("\\"):
1169 buildlines.append(line[:-1])
1171 buildlines.append(line)
1172 build = parse_buildline(buildlines)
1173 app.builds.append(build)
1174 add_comments('build:' + app.builds[-1].vercode)
1178 # Mode at end of file should always be 0
1180 raise MetaDataException(f + " not terminated in " + mf.name)
1182 raise MetaDataException("Unterminated continuation in " + mf.name)
1184 raise MetaDataException("Unterminated build in " + mf.name)
1189 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1191 def w_comments(key):
1192 if key not in app.comments:
1194 for line in app.comments[key]:
1197 def w_field_always(f, v=None):
1199 v = app.get_field(f)
1203 def w_field_nonempty(f, v=None):
1205 v = app.get_field(f)
1210 w_field_nonempty('Disabled')
1211 w_field_nonempty('AntiFeatures')
1212 w_field_nonempty('Provides')
1213 w_field_always('Categories')
1214 w_field_always('License')
1215 w_field_nonempty('Author Name')
1216 w_field_nonempty('Author Email')
1217 w_field_always('Web Site')
1218 w_field_always('Source Code')
1219 w_field_always('Issue Tracker')
1220 w_field_nonempty('Changelog')
1221 w_field_nonempty('Donate')
1222 w_field_nonempty('FlattrID')
1223 w_field_nonempty('Bitcoin')
1224 w_field_nonempty('Litecoin')
1226 w_field_nonempty('Name')
1227 w_field_nonempty('Auto Name')
1228 w_field_always('Summary')
1229 w_field_always('Description', description_txt(app.Description))
1231 if app.RequiresRoot:
1232 w_field_always('Requires Root', 'yes')
1235 w_field_always('Repo Type')
1236 w_field_always('Repo')
1238 w_field_always('Binaries')
1241 for build in app.builds:
1243 if build.version == "Ignore":
1246 w_comments('build:' + build.vercode)
1250 if app.MaintainerNotes:
1251 w_field_always('Maintainer Notes', app.MaintainerNotes)
1254 w_field_nonempty('Archive Policy')
1255 w_field_always('Auto Update Mode')
1256 w_field_always('Update Check Mode')
1257 w_field_nonempty('Update Check Ignore')
1258 w_field_nonempty('Vercode Operation')
1259 w_field_nonempty('Update Check Name')
1260 w_field_nonempty('Update Check Data')
1261 if app.CurrentVersion:
1262 w_field_always('Current Version')
1263 w_field_always('Current Version Code')
1264 if app.NoSourceSince:
1266 w_field_always('No Source Since')
1270 # Write a metadata file in txt format.
1272 # 'mf' - Writer interface (file, StringIO, ...)
1273 # 'app' - The app data
1274 def write_txt(mf, app):
1276 def w_comment(line):
1277 mf.write("# %s\n" % line)
1283 elif t == TYPE_MULTILINE:
1284 v = '\n' + v + '\n.'
1285 mf.write("%s:%s\n" % (f, v))
1288 mf.write("Build:%s,%s\n" % (build.version, build.vercode))
1290 for f in build_flags_order:
1291 v = build.get_flag(f)
1296 mf.write(' %s=' % f)
1297 if t == TYPE_STRING:
1299 elif t == TYPE_BOOL:
1301 elif t == TYPE_SCRIPT:
1303 for s in v.split(' && '):
1307 mf.write(' && \\\n ')
1309 elif t == TYPE_LIST:
1310 mf.write(','.join(v))
1314 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1317 def write_yaml(mf, app):
1319 def w_comment(line):
1320 mf.write("# %s\n" % line)
1325 if any(c in v for c in [': ', '%', '@', '*']):
1326 return "'" + v.replace("'", "''") + "'"
1329 def w_field(f, v, prefix='', t=None):
1336 v += prefix + ' - ' + escape(e) + '\n'
1337 elif t == TYPE_MULTILINE:
1339 for l in v.splitlines():
1341 v += prefix + ' ' + l + '\n'
1344 elif t == TYPE_BOOL:
1346 elif t == TYPE_SCRIPT:
1347 cmds = [s + '&& \\' for s in v.split('&& ')]
1349 cmds[-1] = cmds[-1][:-len('&& \\')]
1350 w_field(f, cmds, prefix, 'multiline')
1353 v = ' ' + escape(v) + '\n'
1366 mf.write("builds:\n")
1369 w_field('versionName', build.version, ' - ', TYPE_STRING)
1370 w_field('versionCode', build.vercode, ' ', TYPE_STRING)
1371 for f in build_flags_order:
1372 v = build.get_flag(f)
1376 w_field(f, v, ' ', flagtype(f))
1378 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1381 def write_metadata(metadatapath, app):
1382 _, ext = fdroidserver.common.get_extension(metadatapath)
1383 accepted = fdroidserver.common.config['accepted_formats']
1384 if ext not in accepted:
1385 raise MetaDataException('Cannot write "%s", not an accepted format, use: %s' % (
1386 metadatapath, ', '.join(accepted)))
1388 with open(metadatapath, 'w', encoding='utf8') as mf:
1390 return write_txt(mf, app)
1392 return write_yaml(mf, app)
1393 raise MetaDataException('Unknown metadata format: %s' % metadatapath)