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 from collections import OrderedDict
40 import fdroidserver.common
41 from fdroidserver.exception import MetaDataException
44 warnings_action = None
47 def warn_or_exception(value):
48 '''output warning or Exception depending on -W'''
49 if warnings_action == 'ignore':
51 elif warnings_action == 'error':
52 raise MetaDataException(value)
57 # To filter which ones should be written to the metadata files if
88 'Update Check Ignore',
93 'Current Version Code',
97 'comments', # For formats that don't do inline comments
98 'builds', # For formats that do builds as a list
104 def __init__(self, copydict=None):
106 super().__init__(copydict)
111 self.AntiFeatures = []
113 self.Categories = ['None']
114 self.License = 'Unknown'
115 self.AuthorName = None
116 self.AuthorEmail = None
117 self.AuthorWebSite = None
120 self.IssueTracker = ''
129 self.Description = ''
130 self.RequiresRoot = False
134 self.MaintainerNotes = ''
135 self.ArchivePolicy = None
136 self.AutoUpdateMode = 'None'
137 self.UpdateCheckMode = 'None'
138 self.UpdateCheckIgnore = None
139 self.VercodeOperation = None
140 self.UpdateCheckName = None
141 self.UpdateCheckData = None
142 self.CurrentVersion = ''
143 self.CurrentVersionCode = None
144 self.NoSourceSince = ''
147 self.metadatapath = None
151 self.lastUpdated = None
153 def __getattr__(self, name):
157 raise AttributeError("No such attribute: " + name)
159 def __setattr__(self, name, value):
162 def __delattr__(self, name):
166 raise AttributeError("No such attribute: " + name)
168 def get_last_build(self):
169 if len(self.builds) > 0:
170 return self.builds[-1]
186 'Description': TYPE_MULTILINE,
187 'MaintainerNotes': TYPE_MULTILINE,
188 'Categories': TYPE_LIST,
189 'AntiFeatures': TYPE_LIST,
190 'BuildVersion': TYPE_BUILD,
191 'Build': TYPE_BUILD_V2,
192 'UseBuilt': TYPE_OBSOLETE,
197 name = name.replace(' ', '')
198 if name in fieldtypes:
199 return fieldtypes[name]
203 # In the order in which they are laid out on files
204 build_flags_order = [
237 # old .txt format has version name/code inline in the 'Build:' line
238 # but YAML and JSON have a explicit key for them
239 build_flags = ['versionName', 'versionCode'] + build_flags_order
244 def __init__(self, copydict=None):
249 self.submodules = False
255 self.buildozer = False
258 self.oldsdkloc = False
260 self.forceversion = False
261 self.forcevercode = False
265 self.androidupdate = []
272 self.preassemble = []
273 self.gradleprops = []
274 self.antcommands = []
275 self.novcheck = False
277 super().__init__(copydict)
280 def __getattr__(self, name):
284 raise AttributeError("No such attribute: " + name)
286 def __setattr__(self, name, value):
289 def __delattr__(self, name):
293 raise AttributeError("No such attribute: " + name)
295 def build_method(self):
296 for f in ['maven', 'gradle', 'kivy', 'buildozer']:
303 # like build_method, but prioritize output=
304 def output_method(self):
307 for f in ['maven', 'gradle', 'kivy', 'buildozer']:
315 version = 'r12b' # falls back to latest
316 paths = fdroidserver.common.config['ndk_paths']
317 if version not in paths:
319 return paths[version]
323 'extlibs': TYPE_LIST,
324 'srclibs': TYPE_LIST,
327 'buildjni': TYPE_LIST,
328 'preassemble': TYPE_LIST,
329 'androidupdate': TYPE_LIST,
330 'scanignore': TYPE_LIST,
331 'scandelete': TYPE_LIST,
333 'antcommands': TYPE_LIST,
334 'gradleprops': TYPE_LIST,
336 'prebuild': TYPE_SCRIPT,
337 'build': TYPE_SCRIPT,
338 'submodules': TYPE_BOOL,
339 'oldsdkloc': TYPE_BOOL,
340 'forceversion': TYPE_BOOL,
341 'forcevercode': TYPE_BOOL,
342 'novcheck': TYPE_BOOL,
347 if name in flagtypes:
348 return flagtypes[name]
352 class FieldValidator():
354 Designates App metadata field types and checks that it matches
356 'name' - The long name of the field type
357 'matching' - List of possible values or regex expression
358 'sep' - Separator to use if value may be a list
359 'fields' - Metadata fields (Field:Value) of this type
362 def __init__(self, name, matching, fields):
364 self.matching = matching
365 self.compiled = re.compile(matching)
368 def check(self, v, appid):
376 if not self.compiled.match(v):
377 warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
378 % (v, self.name, appid, self.matching))
381 # Generic value types
383 FieldValidator("Hexadecimal",
387 FieldValidator("HTTP link",
389 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
391 FieldValidator("Email",
392 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
395 FieldValidator("Bitcoin address",
396 r'^[a-zA-Z0-9]{27,34}$',
399 FieldValidator("Litecoin address",
400 r'^L[a-zA-Z0-9]{33}$',
403 FieldValidator("Repo Type",
404 r'^(git|git-svn|svn|hg|bzr|srclib)$',
407 FieldValidator("Binaries",
411 FieldValidator("Archive Policy",
412 r'^[0-9]+ versions$',
415 FieldValidator("Anti-Feature",
416 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln)$',
419 FieldValidator("Auto Update Mode",
420 r"^(Version .+|None)$",
423 FieldValidator("Update Check Mode",
424 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
429 # Check an app's metadata information for integrity errors
430 def check_metadata(app):
433 v.check(app[k], app.id)
436 # Formatter for descriptions. Create an instance, and call parseline() with
437 # each line of the description source from the metadata. At the end, call
438 # end() and then text_txt and text_html will contain the result.
439 class DescriptionFormatter:
446 def __init__(self, linkres):
449 self.state = self.stNONE
450 self.laststate = self.stNONE
453 self.html = io.StringIO()
454 self.text = io.StringIO()
456 self.linkResolver = None
457 self.linkResolver = linkres
459 def endcur(self, notstates=None):
460 if notstates and self.state in notstates:
462 if self.state == self.stPARA:
464 elif self.state == self.stUL:
466 elif self.state == self.stOL:
470 self.laststate = self.state
471 self.state = self.stNONE
472 whole_para = ' '.join(self.para_lines)
473 self.addtext(whole_para)
474 wrapped = textwrap.fill(whole_para, 80,
475 break_long_words=False,
476 break_on_hyphens=False)
477 self.text.write(wrapped)
478 self.html.write('</p>')
479 del self.para_lines[:]
482 self.html.write('</ul>')
483 self.laststate = self.state
484 self.state = self.stNONE
487 self.html.write('</ol>')
488 self.laststate = self.state
489 self.state = self.stNONE
491 def formatted(self, txt, htmlbody):
494 txt = html.escape(txt, quote=False)
496 index = txt.find("''")
501 if txt.startswith("'''"):
507 self.bold = not self.bold
515 self.ital = not self.ital
518 def linkify(self, txt):
522 index = txt.find("[")
524 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
525 res_plain += self.formatted(txt[:index], False)
526 res_html += self.formatted(txt[:index], True)
528 if txt.startswith("[["):
529 index = txt.find("]]")
531 warn_or_exception("Unterminated ]]")
533 if self.linkResolver:
534 url, urltext = self.linkResolver(url)
537 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
539 txt = txt[index + 2:]
541 index = txt.find("]")
543 warn_or_exception("Unterminated ]")
545 index2 = url.find(' ')
549 urltxt = url[index2 + 1:]
552 warn_or_exception("Url title is just the URL - use [url]")
553 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
556 res_plain += ' (' + url + ')'
557 txt = txt[index + 1:]
559 def addtext(self, txt):
560 p, h = self.linkify(txt)
563 def parseline(self, line):
566 elif line.startswith('* '):
567 self.endcur([self.stUL])
568 if self.state != self.stUL:
569 self.html.write('<ul>')
570 self.state = self.stUL
571 if self.laststate != self.stNONE:
572 self.text.write('\n\n')
574 self.text.write('\n')
575 self.text.write(line)
576 self.html.write('<li>')
577 self.addtext(line[1:])
578 self.html.write('</li>')
579 elif line.startswith('# '):
580 self.endcur([self.stOL])
581 if self.state != self.stOL:
582 self.html.write('<ol>')
583 self.state = self.stOL
584 if self.laststate != self.stNONE:
585 self.text.write('\n\n')
587 self.text.write('\n')
588 self.text.write(line)
589 self.html.write('<li>')
590 self.addtext(line[1:])
591 self.html.write('</li>')
593 self.para_lines.append(line)
594 self.endcur([self.stPARA])
595 if self.state == self.stNONE:
596 self.state = self.stPARA
597 if self.laststate != self.stNONE:
598 self.text.write('\n\n')
599 self.html.write('<p>')
603 self.text_txt = self.text.getvalue()
604 self.text_html = self.html.getvalue()
609 # Parse multiple lines of description as written in a metadata file, returning
610 # a single string in text format and wrapped to 80 columns.
611 def description_txt(s):
612 ps = DescriptionFormatter(None)
613 for line in s.splitlines():
619 # Parse multiple lines of description as written in a metadata file, returning
620 # a single string in wiki format. Used for the Maintainer Notes field as well,
621 # because it's the same format.
622 def description_wiki(s):
626 # Parse multiple lines of description as written in a metadata file, returning
627 # a single string in HTML format.
628 def description_html(s, linkres):
629 ps = DescriptionFormatter(linkres)
630 for line in s.splitlines():
636 def parse_srclib(metadatapath):
640 # Defaults for fields that come from metadata
641 thisinfo['Repo Type'] = ''
642 thisinfo['Repo'] = ''
643 thisinfo['Subdir'] = None
644 thisinfo['Prepare'] = None
646 if not os.path.exists(metadatapath):
649 metafile = open(metadatapath, "r", encoding='utf-8')
652 for line in metafile:
654 line = line.rstrip('\r\n')
655 if not line or line.startswith("#"):
659 f, v = line.split(':', 1)
661 warn_or_exception("Invalid metadata in %s:%d" % (line, n))
664 thisinfo[f] = v.split(',')
674 """Read all srclib metadata.
676 The information read will be accessible as metadata.srclibs, which is a
677 dictionary, keyed on srclib name, with the values each being a dictionary
678 in the same format as that returned by the parse_srclib function.
680 A MetaDataException is raised if there are any problems with the srclib
685 # They were already loaded
686 if srclibs is not None:
692 if not os.path.exists(srcdir):
695 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
696 srclibname = os.path.basename(metadatapath[:-4])
697 srclibs[srclibname] = parse_srclib(metadatapath)
700 def read_metadata(xref=True, check_vcs=[]):
702 Read all metadata. Returns a list of 'app' objects (which are dictionaries as
703 returned by the parse_txt_metadata function.
705 check_vcs is the list of packageNames to check for .fdroid.yml in source
708 # Always read the srclibs before the apps, since they can use a srlib as
709 # their source repository.
714 for basedir in ('metadata', 'tmp'):
715 if not os.path.exists(basedir):
718 # If there are multiple metadata files for a single appid, then the first
719 # file that is parsed wins over all the others, and the rest throw an
720 # exception. So the original .txt format is parsed first, at least until
721 # newer formats stabilize.
723 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
724 + glob.glob(os.path.join('metadata', '*.json'))
725 + glob.glob(os.path.join('metadata', '*.yml'))
726 + glob.glob('.fdroid.json')
727 + glob.glob('.fdroid.yml')):
728 packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
729 if packageName in apps:
730 warn_or_exception("Found multiple metadata files for " + packageName)
731 app = parse_metadata(metadatapath, packageName in check_vcs)
736 # Parse all descriptions at load time, just to ensure cross-referencing
737 # errors are caught early rather than when they hit the build server.
740 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
741 warn_or_exception("Cannot resolve app id " + appid)
743 for appid, app in apps.items():
745 description_html(app.Description, linkres)
746 except MetaDataException as e:
747 warn_or_exception("Problem with description of " + appid +
753 # Port legacy ';' separators
754 list_sep = re.compile(r'[,;]')
757 def split_list_values(s):
759 for v in re.split(list_sep, s):
769 def get_default_app_info(metadatapath=None):
770 if metadatapath is None:
773 appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
775 if appid == '.fdroid': # we have local metadata in the app's source
776 if os.path.exists('AndroidManifest.xml'):
777 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
779 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
780 for root, dirs, files in os.walk(os.getcwd()):
781 if 'build.gradle' in files:
782 p = os.path.join(root, 'build.gradle')
783 with open(p, 'rb') as f:
785 m = pattern.search(data)
787 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
788 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
790 if manifestroot is None:
791 warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
792 appid = manifestroot.attrib['package']
795 app.metadatapath = metadatapath
796 if appid is not None:
802 def sorted_builds(builds):
803 return sorted(builds, key=lambda build: int(build.versionCode))
806 esc_newlines = re.compile(r'\\( |\n)')
809 def post_metadata_parse(app):
810 # TODO keep native types, convert only for .txt metadata
811 for k, v in app.items():
812 if type(v) in (float, int):
815 if isinstance(app.Categories, str):
816 app.Categories = [app.Categories]
817 elif app.Categories is None:
818 app.Categories = ['None']
820 app.Categories = [str(i) for i in app.Categories]
824 for build in app['builds']:
825 if not isinstance(build, Build):
827 for k, v in build.items():
828 if flagtype(k) == TYPE_LIST:
829 if isinstance(v, str):
831 elif isinstance(v, bool):
836 elif flagtype(k) == TYPE_STRING and type(v) in (float, int):
840 app.builds = sorted_builds(builds)
843 # Parse metadata for a single application.
845 # 'metadatapath' - the filename to read. The package id for the application comes
846 # from this filename. Pass None to get a blank entry.
848 # Returns a dictionary containing all the details of the application. There are
849 # two major kinds of information in the dictionary. Keys beginning with capital
850 # letters correspond directory to identically named keys in the metadata file.
851 # Keys beginning with lower case letters are generated in one way or another,
852 # and are not found verbatim in the metadata.
854 # Known keys not originating from the metadata are:
856 # 'builds' - a list of dictionaries containing build information
857 # for each defined build
858 # 'comments' - a list of comments from the metadata file. Each is
859 # a list of the form [field, comment] where field is
860 # the name of the field it preceded in the metadata
861 # file. Where field is None, the comment goes at the
862 # end of the file. Alternatively, 'build:version' is
863 # for a comment before a particular build version.
864 # 'descriptionlines' - original lines of description as formatted in the
869 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
870 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
874 if bool_true.match(s):
876 if bool_false.match(s):
878 warn_or_exception("Invalid bool '%s'" % s)
881 def parse_metadata(metadatapath, check_vcs=False):
882 '''parse metadata file, optionally checking the git repo for metadata first'''
884 _, ext = fdroidserver.common.get_extension(metadatapath)
885 accepted = fdroidserver.common.config['accepted_formats']
886 if ext not in accepted:
887 warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
888 metadatapath, ', '.join(accepted)))
891 app.metadatapath = metadatapath
892 name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
893 if name == '.fdroid':
898 with open(metadatapath, 'r', encoding='utf-8') as mf:
900 parse_txt_metadata(mf, app)
902 parse_json_metadata(mf, app)
904 parse_yaml_metadata(mf, app)
906 warn_or_exception('Unknown metadata format: %s' % metadatapath)
908 if check_vcs and app.Repo:
909 build_dir = fdroidserver.common.get_build_dir(app)
910 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
911 if not os.path.isfile(metadata_in_repo):
912 vcs, build_dir = fdroidserver.common.setup_vcs(app)
913 if isinstance(vcs, fdroidserver.common.vcs_git):
914 vcs.gotorevision('HEAD') # HEAD since we can't know where else to go
915 if os.path.isfile(metadata_in_repo):
916 logging.debug('Including metadata from ' + metadata_in_repo)
917 # do not include fields already provided by main metadata file
918 app_in_repo = parse_metadata(metadata_in_repo)
919 for k, v in app_in_repo.items():
923 post_metadata_parse(app)
927 build = app.builds[-1]
929 root_dir = build.subdir
932 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
933 _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
938 def parse_json_metadata(mf, app):
940 # fdroid metadata is only strings and booleans, no floats or ints.
941 # TODO create schema using https://pypi.python.org/pypi/jsonschema
942 jsoninfo = json.load(mf, parse_int=lambda s: s,
943 parse_float=lambda s: s)
945 for f in ['Description', 'Maintainer Notes']:
948 app[f] = '\n'.join(v)
952 def parse_yaml_metadata(mf, app):
954 yamlinfo = yaml.load(mf, Loader=YamlLoader)
958 def write_yaml(mf, app):
960 def _class_as_dict_representer(dumper, data):
961 '''Creates a YAML representation of a App/Build instance'''
962 return dumper.represent_dict(data)
964 empty_keys = [k for k, v in app.items() if not v]
968 for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
972 #yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
973 #ruamel.yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
974 #yaml.dump(app.asOrderedDict(), mf, default_flow_style=False, Dumper=yamlordereddictloader.Dumper)
976 yaml_app_field_order = [
995 'Current Version Code',
998 preformated = ruamel.yaml.comments.CommentedMap()
999 insert_newline = False
1000 for field in yaml_app_field_order:
1002 insert_newline = True
1004 f = field.replace(' ', '')
1005 if hasattr(app, f) and getattr(app, f):
1006 if f in ['Description']:
1007 preformated.update({f: ruamel.yaml.scalarstring.preserve_literal(getattr(app, f))})
1009 preformated.update({f: getattr(app, f)})
1011 insert_newline = False
1012 # inserting empty lines is not supported so we add a
1013 # bogus comment and over-write its value
1014 preformated.yaml_set_comment_before_after_key(f, 'bogus')
1015 preformated.ca.items[f][1][0].value = '\n'
1016 # TODO implement dump for builds
1017 del(preformated['builds'])
1019 ruamel.yaml.round_trip_dump(preformated, mf, indent=4, block_seq_indent=2)
1022 def write_yaml(mf, app):
1024 def _class_as_dict_representer(dumper, data):
1025 '''Creates a YAML representation of a App/Build instance'''
1026 return dumper.represent_dict(data)
1028 empty_keys = [k for k, v in app.items() if not v]
1029 for k in empty_keys:
1032 for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
1036 yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
1037 yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
1038 yaml.dump(app, mf, default_flow_style=False)
1041 build_line_sep = re.compile(r'(?<!\\),')
1042 build_cont = re.compile(r'^[ \t]')
1045 def parse_txt_metadata(mf, app):
1049 def add_buildflag(p, build):
1051 warn_or_exception("Empty build flag at {1}"
1052 .format(buildlines[0], linedesc))
1053 bv = p.split('=', 1)
1055 warn_or_exception("Invalid build flag at {0} in {1}"
1056 .format(buildlines[0], linedesc))
1061 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
1064 pv = split_list_values(pv)
1066 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1068 elif t == TYPE_BOOL:
1069 build[pk] = _decode_bool(pv)
1071 def parse_buildline(lines):
1073 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1075 warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1077 build.versionName = parts[0]
1078 build.versionCode = parts[1]
1079 check_versionCode(build.versionCode)
1081 if parts[2].startswith('!'):
1082 # For backwards compatibility, handle old-style disabling,
1083 # including attempting to extract the commit from the message
1084 build.disable = parts[2][1:]
1085 commit = 'unknown - see disabled'
1086 index = parts[2].rfind('at ')
1088 commit = parts[2][index + 3:]
1089 if commit.endswith(')'):
1090 commit = commit[:-1]
1091 build.commit = commit
1093 build.commit = parts[2]
1095 add_buildflag(p, build)
1099 def check_versionCode(versionCode):
1103 warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1105 def add_comments(key):
1108 app.comments[key] = list(curcomments)
1113 multiline_lines = []
1123 linedesc = "%s:%d" % (mf.name, c)
1124 line = line.rstrip('\r\n')
1126 if build_cont.match(line):
1127 if line.endswith('\\'):
1128 buildlines.append(line[:-1].lstrip())
1130 buildlines.append(line.lstrip())
1131 bl = ''.join(buildlines)
1132 add_buildflag(bl, build)
1135 if not build.commit and not build.disable:
1136 warn_or_exception("No commit specified for {0} in {1}"
1137 .format(build.versionName, linedesc))
1139 app.builds.append(build)
1140 add_comments('build:' + build.versionCode)
1146 if line.startswith("#"):
1147 curcomments.append(line[1:].strip())
1150 f, v = line.split(':', 1)
1152 warn_or_exception("Invalid metadata in " + linedesc)
1154 if f not in app_fields:
1155 warn_or_exception('Unrecognised app field: ' + f)
1157 # Translate obsolete fields...
1158 if f == 'Market Version':
1159 f = 'Current Version'
1160 if f == 'Market Version Code':
1161 f = 'Current Version Code'
1163 f = f.replace(' ', '')
1165 ftype = fieldtype(f)
1166 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1168 if ftype == TYPE_MULTILINE:
1171 warn_or_exception("Unexpected text on same line as "
1172 + f + " in " + linedesc)
1173 elif ftype == TYPE_STRING:
1175 elif ftype == TYPE_LIST:
1176 app[f] = split_list_values(v)
1177 elif ftype == TYPE_BUILD:
1178 if v.endswith("\\"):
1181 buildlines.append(v[:-1])
1183 build = parse_buildline([v])
1184 app.builds.append(build)
1185 add_comments('build:' + app.builds[-1].versionCode)
1186 elif ftype == TYPE_BUILD_V2:
1189 warn_or_exception('Build should have comma-separated',
1190 'versionName and versionCode,',
1191 'not "{0}", in {1}'.format(v, linedesc))
1193 build.versionName = vv[0]
1194 build.versionCode = vv[1]
1195 check_versionCode(build.versionCode)
1197 if build.versionCode in vc_seen:
1198 warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1199 % (build.versionCode, linedesc))
1200 vc_seen.add(build.versionCode)
1203 elif ftype == TYPE_OBSOLETE:
1204 pass # Just throw it away!
1206 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1207 elif mode == 1: # Multiline field
1210 app[f] = '\n'.join(multiline_lines)
1211 del multiline_lines[:]
1213 multiline_lines.append(line)
1214 elif mode == 2: # Line continuation mode in Build Version
1215 if line.endswith("\\"):
1216 buildlines.append(line[:-1])
1218 buildlines.append(line)
1219 build = parse_buildline(buildlines)
1220 app.builds.append(build)
1221 add_comments('build:' + app.builds[-1].versionCode)
1225 # Mode at end of file should always be 0
1227 warn_or_exception(f + " not terminated in " + mf.name)
1229 warn_or_exception("Unterminated continuation in " + mf.name)
1231 warn_or_exception("Unterminated build in " + mf.name)
1236 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1238 def field_to_attr(f):
1240 Translates human-readable field names to attribute names, e.g.
1241 'Auto Name' to 'AutoName'
1243 return f.replace(' ', '')
1245 def attr_to_field(k):
1247 Translates attribute names to human-readable field names, e.g.
1248 'AutoName' to 'Auto Name'
1252 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1255 def w_comments(key):
1256 if key not in app.comments:
1258 for line in app.comments[key]:
1261 def w_field_always(f, v=None):
1262 key = field_to_attr(f)
1268 def w_field_nonempty(f, v=None):
1269 key = field_to_attr(f)
1276 w_field_nonempty('Disabled')
1277 w_field_nonempty('AntiFeatures')
1278 w_field_nonempty('Provides')
1279 w_field_always('Categories')
1280 w_field_always('License')
1281 w_field_nonempty('Author Name')
1282 w_field_nonempty('Author Email')
1283 w_field_nonempty('Author Web Site')
1284 w_field_always('Web Site')
1285 w_field_always('Source Code')
1286 w_field_always('Issue Tracker')
1287 w_field_nonempty('Changelog')
1288 w_field_nonempty('Donate')
1289 w_field_nonempty('FlattrID')
1290 w_field_nonempty('Bitcoin')
1291 w_field_nonempty('Litecoin')
1293 w_field_nonempty('Name')
1294 w_field_nonempty('Auto Name')
1295 w_field_nonempty('Summary')
1296 w_field_nonempty('Description', description_txt(app.Description))
1298 if app.RequiresRoot:
1299 w_field_always('Requires Root', 'yes')
1302 w_field_always('Repo Type')
1303 w_field_always('Repo')
1305 w_field_always('Binaries')
1308 for build in app.builds:
1310 if build.versionName == "Ignore":
1313 w_comments('build:%s' % build.versionCode)
1317 if app.MaintainerNotes:
1318 w_field_always('Maintainer Notes', app.MaintainerNotes)
1321 w_field_nonempty('Archive Policy')
1322 w_field_always('Auto Update Mode')
1323 w_field_always('Update Check Mode')
1324 w_field_nonempty('Update Check Ignore')
1325 w_field_nonempty('Vercode Operation')
1326 w_field_nonempty('Update Check Name')
1327 w_field_nonempty('Update Check Data')
1328 if app.CurrentVersion:
1329 w_field_always('Current Version')
1330 w_field_always('Current Version Code')
1331 if app.NoSourceSince:
1333 w_field_always('No Source Since')
1337 # Write a metadata file in txt format.
1339 # 'mf' - Writer interface (file, StringIO, ...)
1340 # 'app' - The app data
1341 def write_txt(mf, app):
1343 def w_comment(line):
1344 mf.write("# %s\n" % line)
1350 elif t == TYPE_MULTILINE:
1351 v = '\n' + v + '\n.'
1352 mf.write("%s:%s\n" % (f, v))
1355 mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1357 for f in build_flags_order:
1363 if f == 'androidupdate':
1364 f = 'update' # avoid conflicting with Build(dict).update()
1365 mf.write(' %s=' % f)
1366 if t == TYPE_STRING:
1368 elif t == TYPE_BOOL:
1370 elif t == TYPE_SCRIPT:
1372 for s in v.split(' && '):
1376 mf.write(' && \\\n ')
1378 elif t == TYPE_LIST:
1379 mf.write(','.join(v))
1383 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1386 def write_metadata(metadatapath, app):
1387 _, ext = fdroidserver.common.get_extension(metadatapath)
1388 accepted = fdroidserver.common.config['accepted_formats']
1389 if ext not in accepted:
1390 warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1391 % (metadatapath, ', '.join(accepted)))
1393 with open(metadatapath, 'w', encoding='utf8') as mf:
1395 return write_txt(mf, app)
1397 return write_yaml(mf, app)
1398 warn_or_exception('Unknown metadata format: %s' % metadatapath)
1401 def add_metadata_arguments(parser):
1402 '''add common command line flags related to metadata processing'''
1403 parser.add_argument("-W", default='error',
1404 help="force errors to be warnings, or ignore")