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
39 from fdroidserver.exception import MetaDataException
42 warnings_action = None
45 def warn_or_exception(value):
46 '''output warning or Exception depending on -W'''
47 if warnings_action == 'ignore':
49 elif warnings_action == 'error':
50 raise MetaDataException(value)
55 # To filter which ones should be written to the metadata files if
86 'Update Check Ignore',
91 'Current Version Code',
95 'comments', # For formats that don't do inline comments
96 'builds', # For formats that do builds as a list
102 def __init__(self, copydict=None):
104 super().__init__(copydict)
109 self.AntiFeatures = []
111 self.Categories = ['None']
112 self.License = 'Unknown'
113 self.AuthorName = None
114 self.AuthorEmail = None
115 self.AuthorWebSite = None
118 self.IssueTracker = ''
127 self.Description = ''
128 self.RequiresRoot = False
132 self.MaintainerNotes = ''
133 self.ArchivePolicy = None
134 self.AutoUpdateMode = 'None'
135 self.UpdateCheckMode = 'None'
136 self.UpdateCheckIgnore = None
137 self.VercodeOperation = None
138 self.UpdateCheckName = None
139 self.UpdateCheckData = None
140 self.CurrentVersion = ''
141 self.CurrentVersionCode = None
142 self.NoSourceSince = ''
145 self.metadatapath = None
149 self.lastUpdated = None
151 def __getattr__(self, name):
155 raise AttributeError("No such attribute: " + name)
157 def __setattr__(self, name, value):
160 def __delattr__(self, name):
164 raise AttributeError("No such attribute: " + name)
166 def get_last_build(self):
167 if len(self.builds) > 0:
168 return self.builds[-1]
184 'Description': TYPE_MULTILINE,
185 'MaintainerNotes': TYPE_MULTILINE,
186 'Categories': TYPE_LIST,
187 'AntiFeatures': TYPE_LIST,
188 'BuildVersion': TYPE_BUILD,
189 'Build': TYPE_BUILD_V2,
190 'UseBuilt': TYPE_OBSOLETE,
195 name = name.replace(' ', '')
196 if name in fieldtypes:
197 return fieldtypes[name]
201 # In the order in which they are laid out on files
202 build_flags_order = [
234 # old .txt format has version name/code inline in the 'Build:' line
235 # but YAML and JSON have a explicit key for them
236 build_flags = ['versionName', 'versionCode'] + build_flags_order
241 def __init__(self, copydict=None):
246 self.submodules = False
254 self.oldsdkloc = False
256 self.forceversion = False
257 self.forcevercode = False
261 self.androidupdate = []
268 self.preassemble = []
269 self.gradleprops = []
270 self.antcommands = []
271 self.novcheck = False
273 super().__init__(copydict)
276 def __getattr__(self, name):
280 raise AttributeError("No such attribute: " + name)
282 def __setattr__(self, name, value):
285 def __delattr__(self, name):
289 raise AttributeError("No such attribute: " + name)
291 def build_method(self):
292 for f in ['maven', 'gradle', 'kivy']:
299 # like build_method, but prioritize output=
300 def output_method(self):
303 for f in ['maven', 'gradle', 'kivy']:
311 version = 'r12b' # falls back to latest
312 paths = fdroidserver.common.config['ndk_paths']
313 if version not in paths:
315 return paths[version]
319 'extlibs': TYPE_LIST,
320 'srclibs': TYPE_LIST,
323 'buildjni': TYPE_LIST,
324 'preassemble': TYPE_LIST,
325 'androidupdate': TYPE_LIST,
326 'scanignore': TYPE_LIST,
327 'scandelete': TYPE_LIST,
329 'antcommands': TYPE_LIST,
330 'gradleprops': TYPE_LIST,
332 'prebuild': TYPE_SCRIPT,
333 'build': TYPE_SCRIPT,
334 'submodules': TYPE_BOOL,
335 'oldsdkloc': TYPE_BOOL,
336 'forceversion': TYPE_BOOL,
337 'forcevercode': TYPE_BOOL,
338 'novcheck': TYPE_BOOL,
343 if name in flagtypes:
344 return flagtypes[name]
348 class FieldValidator():
350 Designates App metadata field types and checks that it matches
352 'name' - The long name of the field type
353 'matching' - List of possible values or regex expression
354 'sep' - Separator to use if value may be a list
355 'fields' - Metadata fields (Field:Value) of this type
358 def __init__(self, name, matching, fields):
360 self.matching = matching
361 self.compiled = re.compile(matching)
364 def check(self, v, appid):
372 if not self.compiled.match(v):
373 warn_or_exception("'%s' is not a valid %s in %s. Regex pattern: %s"
374 % (v, self.name, appid, self.matching))
377 # Generic value types
379 FieldValidator("Hexadecimal",
383 FieldValidator("HTTP link",
385 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
387 FieldValidator("Email",
388 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
391 FieldValidator("Bitcoin address",
392 r'^[a-zA-Z0-9]{27,34}$',
395 FieldValidator("Litecoin address",
396 r'^L[a-zA-Z0-9]{33}$',
399 FieldValidator("Repo Type",
400 r'^(git|git-svn|svn|hg|bzr|srclib)$',
403 FieldValidator("Binaries",
407 FieldValidator("Archive Policy",
408 r'^[0-9]+ versions$',
411 FieldValidator("Anti-Feature",
412 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln)$',
415 FieldValidator("Auto Update Mode",
416 r"^(Version .+|None)$",
419 FieldValidator("Update Check Mode",
420 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
425 # Check an app's metadata information for integrity errors
426 def check_metadata(app):
429 v.check(app[k], app.id)
432 # Formatter for descriptions. Create an instance, and call parseline() with
433 # each line of the description source from the metadata. At the end, call
434 # end() and then text_txt and text_html will contain the result.
435 class DescriptionFormatter:
442 def __init__(self, linkres):
445 self.state = self.stNONE
446 self.laststate = self.stNONE
449 self.html = io.StringIO()
450 self.text = io.StringIO()
452 self.linkResolver = None
453 self.linkResolver = linkres
455 def endcur(self, notstates=None):
456 if notstates and self.state in notstates:
458 if self.state == self.stPARA:
460 elif self.state == self.stUL:
462 elif self.state == self.stOL:
466 self.laststate = self.state
467 self.state = self.stNONE
468 whole_para = ' '.join(self.para_lines)
469 self.addtext(whole_para)
470 wrapped = textwrap.fill(whole_para, 80,
471 break_long_words=False,
472 break_on_hyphens=False)
473 self.text.write(wrapped)
474 self.html.write('</p>')
475 del self.para_lines[:]
478 self.html.write('</ul>')
479 self.laststate = self.state
480 self.state = self.stNONE
483 self.html.write('</ol>')
484 self.laststate = self.state
485 self.state = self.stNONE
487 def formatted(self, txt, htmlbody):
490 txt = html.escape(txt, quote=False)
492 index = txt.find("''")
497 if txt.startswith("'''"):
503 self.bold = not self.bold
511 self.ital = not self.ital
514 def linkify(self, txt):
518 index = txt.find("[")
520 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
521 res_plain += self.formatted(txt[:index], False)
522 res_html += self.formatted(txt[:index], True)
524 if txt.startswith("[["):
525 index = txt.find("]]")
527 warn_or_exception("Unterminated ]]")
529 if self.linkResolver:
530 url, urltext = self.linkResolver(url)
533 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
535 txt = txt[index + 2:]
537 index = txt.find("]")
539 warn_or_exception("Unterminated ]")
541 index2 = url.find(' ')
545 urltxt = url[index2 + 1:]
548 warn_or_exception("Url title is just the URL - use [url]")
549 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
552 res_plain += ' (' + url + ')'
553 txt = txt[index + 1:]
555 def addtext(self, txt):
556 p, h = self.linkify(txt)
559 def parseline(self, line):
562 elif line.startswith('* '):
563 self.endcur([self.stUL])
564 if self.state != self.stUL:
565 self.html.write('<ul>')
566 self.state = self.stUL
567 if self.laststate != self.stNONE:
568 self.text.write('\n\n')
570 self.text.write('\n')
571 self.text.write(line)
572 self.html.write('<li>')
573 self.addtext(line[1:])
574 self.html.write('</li>')
575 elif line.startswith('# '):
576 self.endcur([self.stOL])
577 if self.state != self.stOL:
578 self.html.write('<ol>')
579 self.state = self.stOL
580 if self.laststate != self.stNONE:
581 self.text.write('\n\n')
583 self.text.write('\n')
584 self.text.write(line)
585 self.html.write('<li>')
586 self.addtext(line[1:])
587 self.html.write('</li>')
589 self.para_lines.append(line)
590 self.endcur([self.stPARA])
591 if self.state == self.stNONE:
592 self.state = self.stPARA
593 if self.laststate != self.stNONE:
594 self.text.write('\n\n')
595 self.html.write('<p>')
599 self.text_txt = self.text.getvalue()
600 self.text_html = self.html.getvalue()
605 # Parse multiple lines of description as written in a metadata file, returning
606 # a single string in text format and wrapped to 80 columns.
607 def description_txt(s):
608 ps = DescriptionFormatter(None)
609 for line in s.splitlines():
615 # Parse multiple lines of description as written in a metadata file, returning
616 # a single string in wiki format. Used for the Maintainer Notes field as well,
617 # because it's the same format.
618 def description_wiki(s):
622 # Parse multiple lines of description as written in a metadata file, returning
623 # a single string in HTML format.
624 def description_html(s, linkres):
625 ps = DescriptionFormatter(linkres)
626 for line in s.splitlines():
632 def parse_srclib(metadatapath):
636 # Defaults for fields that come from metadata
637 thisinfo['Repo Type'] = ''
638 thisinfo['Repo'] = ''
639 thisinfo['Subdir'] = None
640 thisinfo['Prepare'] = None
642 if not os.path.exists(metadatapath):
645 metafile = open(metadatapath, "r", encoding='utf-8')
648 for line in metafile:
650 line = line.rstrip('\r\n')
651 if not line or line.startswith("#"):
655 f, v = line.split(':', 1)
657 warn_or_exception("Invalid metadata in %s:%d" % (line, n))
660 thisinfo[f] = v.split(',')
670 """Read all srclib metadata.
672 The information read will be accessible as metadata.srclibs, which is a
673 dictionary, keyed on srclib name, with the values each being a dictionary
674 in the same format as that returned by the parse_srclib function.
676 A MetaDataException is raised if there are any problems with the srclib
681 # They were already loaded
682 if srclibs is not None:
688 if not os.path.exists(srcdir):
691 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
692 srclibname = os.path.basename(metadatapath[:-4])
693 srclibs[srclibname] = parse_srclib(metadatapath)
696 def read_metadata(xref=True, check_vcs=[]):
698 Read all metadata. Returns a list of 'app' objects (which are dictionaries as
699 returned by the parse_txt_metadata function.
701 check_vcs is the list of packageNames to check for .fdroid.yml in source
704 # Always read the srclibs before the apps, since they can use a srlib as
705 # their source repository.
710 for basedir in ('metadata', 'tmp'):
711 if not os.path.exists(basedir):
714 # If there are multiple metadata files for a single appid, then the first
715 # file that is parsed wins over all the others, and the rest throw an
716 # exception. So the original .txt format is parsed first, at least until
717 # newer formats stabilize.
719 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
720 + glob.glob(os.path.join('metadata', '*.json'))
721 + glob.glob(os.path.join('metadata', '*.yml'))
722 + glob.glob('.fdroid.json')
723 + glob.glob('.fdroid.yml')):
724 packageName, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
725 if packageName in apps:
726 warn_or_exception("Found multiple metadata files for " + packageName)
727 app = parse_metadata(metadatapath, packageName in check_vcs)
732 # Parse all descriptions at load time, just to ensure cross-referencing
733 # errors are caught early rather than when they hit the build server.
736 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
737 warn_or_exception("Cannot resolve app id " + appid)
739 for appid, app in apps.items():
741 description_html(app.Description, linkres)
742 except MetaDataException as e:
743 warn_or_exception("Problem with description of " + appid +
749 # Port legacy ';' separators
750 list_sep = re.compile(r'[,;]')
753 def split_list_values(s):
755 for v in re.split(list_sep, s):
765 def get_default_app_info(metadatapath=None):
766 if metadatapath is None:
769 appid, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
771 if appid == '.fdroid': # we have local metadata in the app's source
772 if os.path.exists('AndroidManifest.xml'):
773 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
775 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
776 for root, dirs, files in os.walk(os.getcwd()):
777 if 'build.gradle' in files:
778 p = os.path.join(root, 'build.gradle')
779 with open(p, 'rb') as f:
781 m = pattern.search(data)
783 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
784 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
786 if manifestroot is None:
787 warn_or_exception("Cannot find a packageName for {0}!".format(metadatapath))
788 appid = manifestroot.attrib['package']
791 app.metadatapath = metadatapath
792 if appid is not None:
798 def sorted_builds(builds):
799 return sorted(builds, key=lambda build: int(build.versionCode))
802 esc_newlines = re.compile(r'\\( |\n)')
805 def post_metadata_parse(app):
806 # TODO keep native types, convert only for .txt metadata
807 for k, v in app.items():
808 if type(v) in (float, int):
811 if isinstance(app.Categories, str):
812 app.Categories = [app.Categories]
813 elif app.Categories is None:
814 app.Categories = ['None']
816 app.Categories = [str(i) for i in app.Categories]
820 for build in app['builds']:
821 if not isinstance(build, Build):
823 for k, v in build.items():
824 if flagtype(k) == TYPE_LIST:
825 if isinstance(v, str):
827 elif isinstance(v, bool):
832 elif flagtype(k) == TYPE_STRING and type(v) in (float, int):
836 app.builds = sorted_builds(builds)
839 # Parse metadata for a single application.
841 # 'metadatapath' - the filename to read. The package id for the application comes
842 # from this filename. Pass None to get a blank entry.
844 # Returns a dictionary containing all the details of the application. There are
845 # two major kinds of information in the dictionary. Keys beginning with capital
846 # letters correspond directory to identically named keys in the metadata file.
847 # Keys beginning with lower case letters are generated in one way or another,
848 # and are not found verbatim in the metadata.
850 # Known keys not originating from the metadata are:
852 # 'builds' - a list of dictionaries containing build information
853 # for each defined build
854 # 'comments' - a list of comments from the metadata file. Each is
855 # a list of the form [field, comment] where field is
856 # the name of the field it preceded in the metadata
857 # file. Where field is None, the comment goes at the
858 # end of the file. Alternatively, 'build:version' is
859 # for a comment before a particular build version.
860 # 'descriptionlines' - original lines of description as formatted in the
865 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
866 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
870 if bool_true.match(s):
872 if bool_false.match(s):
874 warn_or_exception("Invalid bool '%s'" % s)
877 def parse_metadata(metadatapath, check_vcs=False):
878 '''parse metadata file, optionally checking the git repo for metadata first'''
880 _, ext = fdroidserver.common.get_extension(metadatapath)
881 accepted = fdroidserver.common.config['accepted_formats']
882 if ext not in accepted:
883 warn_or_exception('"%s" is not an accepted format, convert to: %s' % (
884 metadatapath, ', '.join(accepted)))
887 app.metadatapath = metadatapath
888 name, _ = fdroidserver.common.get_extension(os.path.basename(metadatapath))
889 if name == '.fdroid':
894 with open(metadatapath, 'r', encoding='utf-8') as mf:
896 parse_txt_metadata(mf, app)
898 parse_json_metadata(mf, app)
900 parse_yaml_metadata(mf, app)
902 warn_or_exception('Unknown metadata format: %s' % metadatapath)
904 if check_vcs and app.Repo:
905 build_dir = fdroidserver.common.get_build_dir(app)
906 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
907 if not os.path.isfile(metadata_in_repo):
908 vcs, build_dir = fdroidserver.common.setup_vcs(app)
909 if isinstance(vcs, fdroidserver.common.vcs_git):
910 vcs.gotorevision('HEAD') # HEAD since we can't know where else to go
911 if os.path.isfile(metadata_in_repo):
912 logging.debug('Including metadata from ' + metadata_in_repo)
913 # do not include fields already provided by main metadata file
914 app_in_repo = parse_metadata(metadata_in_repo)
915 for k, v in app_in_repo.items():
919 post_metadata_parse(app)
923 build = app.builds[-1]
925 root_dir = build.subdir
928 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
929 _, _, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
934 def parse_json_metadata(mf, app):
936 # fdroid metadata is only strings and booleans, no floats or ints.
937 # TODO create schema using https://pypi.python.org/pypi/jsonschema
938 jsoninfo = json.load(mf, parse_int=lambda s: s,
939 parse_float=lambda s: s)
941 for f in ['Description', 'Maintainer Notes']:
944 app[f] = '\n'.join(v)
948 def parse_yaml_metadata(mf, app):
950 yamlinfo = yaml.load(mf, Loader=YamlLoader)
955 def write_yaml(mf, app):
957 def _class_as_dict_representer(dumper, data):
958 '''Creates a YAML representation of a App/Build instance'''
959 return dumper.represent_dict(data)
961 empty_keys = [k for k, v in app.items() if not v]
965 for k in ['added', 'lastUpdated', 'id', 'metadatapath']:
969 yaml.add_representer(fdroidserver.metadata.App, _class_as_dict_representer)
970 yaml.add_representer(fdroidserver.metadata.Build, _class_as_dict_representer)
971 yaml.dump(app, mf, default_flow_style=False)
974 build_line_sep = re.compile(r'(?<!\\),')
975 build_cont = re.compile(r'^[ \t]')
978 def parse_txt_metadata(mf, app):
982 def add_buildflag(p, build):
984 warn_or_exception("Empty build flag at {1}"
985 .format(buildlines[0], linedesc))
988 warn_or_exception("Invalid build flag at {0} in {1}"
989 .format(buildlines[0], linedesc))
994 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
997 pv = split_list_values(pv)
999 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1001 elif t == TYPE_BOOL:
1002 build[pk] = _decode_bool(pv)
1004 def parse_buildline(lines):
1006 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1008 warn_or_exception("Invalid build format: " + v + " in " + mf.name)
1010 build.versionName = parts[0]
1011 build.versionCode = parts[1]
1012 check_versionCode(build.versionCode)
1014 if parts[2].startswith('!'):
1015 # For backwards compatibility, handle old-style disabling,
1016 # including attempting to extract the commit from the message
1017 build.disable = parts[2][1:]
1018 commit = 'unknown - see disabled'
1019 index = parts[2].rfind('at ')
1021 commit = parts[2][index + 3:]
1022 if commit.endswith(')'):
1023 commit = commit[:-1]
1024 build.commit = commit
1026 build.commit = parts[2]
1028 add_buildflag(p, build)
1032 def check_versionCode(versionCode):
1036 warn_or_exception('Invalid versionCode: "' + versionCode + '" is not an integer!')
1038 def add_comments(key):
1041 app.comments[key] = list(curcomments)
1046 multiline_lines = []
1056 linedesc = "%s:%d" % (mf.name, c)
1057 line = line.rstrip('\r\n')
1059 if build_cont.match(line):
1060 if line.endswith('\\'):
1061 buildlines.append(line[:-1].lstrip())
1063 buildlines.append(line.lstrip())
1064 bl = ''.join(buildlines)
1065 add_buildflag(bl, build)
1068 if not build.commit and not build.disable:
1069 warn_or_exception("No commit specified for {0} in {1}"
1070 .format(build.versionName, linedesc))
1072 app.builds.append(build)
1073 add_comments('build:' + build.versionCode)
1079 if line.startswith("#"):
1080 curcomments.append(line[1:].strip())
1083 f, v = line.split(':', 1)
1085 warn_or_exception("Invalid metadata in " + linedesc)
1087 if f not in app_fields:
1088 warn_or_exception('Unrecognised app field: ' + f)
1090 # Translate obsolete fields...
1091 if f == 'Market Version':
1092 f = 'Current Version'
1093 if f == 'Market Version Code':
1094 f = 'Current Version Code'
1096 f = f.replace(' ', '')
1098 ftype = fieldtype(f)
1099 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1101 if ftype == TYPE_MULTILINE:
1104 warn_or_exception("Unexpected text on same line as "
1105 + f + " in " + linedesc)
1106 elif ftype == TYPE_STRING:
1108 elif ftype == TYPE_LIST:
1109 app[f] = split_list_values(v)
1110 elif ftype == TYPE_BUILD:
1111 if v.endswith("\\"):
1114 buildlines.append(v[:-1])
1116 build = parse_buildline([v])
1117 app.builds.append(build)
1118 add_comments('build:' + app.builds[-1].versionCode)
1119 elif ftype == TYPE_BUILD_V2:
1122 warn_or_exception('Build should have comma-separated',
1123 'versionName and versionCode,',
1124 'not "{0}", in {1}'.format(v, linedesc))
1126 build.versionName = vv[0]
1127 build.versionCode = vv[1]
1128 check_versionCode(build.versionCode)
1130 if build.versionCode in vc_seen:
1131 warn_or_exception('Duplicate build recipe found for versionCode %s in %s'
1132 % (build.versionCode, linedesc))
1133 vc_seen.add(build.versionCode)
1136 elif ftype == TYPE_OBSOLETE:
1137 pass # Just throw it away!
1139 warn_or_exception("Unrecognised field '" + f + "' in " + linedesc)
1140 elif mode == 1: # Multiline field
1143 app[f] = '\n'.join(multiline_lines)
1144 del multiline_lines[:]
1146 multiline_lines.append(line)
1147 elif mode == 2: # Line continuation mode in Build Version
1148 if line.endswith("\\"):
1149 buildlines.append(line[:-1])
1151 buildlines.append(line)
1152 build = parse_buildline(buildlines)
1153 app.builds.append(build)
1154 add_comments('build:' + app.builds[-1].versionCode)
1158 # Mode at end of file should always be 0
1160 warn_or_exception(f + " not terminated in " + mf.name)
1162 warn_or_exception("Unterminated continuation in " + mf.name)
1164 warn_or_exception("Unterminated build in " + mf.name)
1169 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1171 def field_to_attr(f):
1173 Translates human-readable field names to attribute names, e.g.
1174 'Auto Name' to 'AutoName'
1176 return f.replace(' ', '')
1178 def attr_to_field(k):
1180 Translates attribute names to human-readable field names, e.g.
1181 'AutoName' to 'Auto Name'
1185 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1188 def w_comments(key):
1189 if key not in app.comments:
1191 for line in app.comments[key]:
1194 def w_field_always(f, v=None):
1195 key = field_to_attr(f)
1201 def w_field_nonempty(f, v=None):
1202 key = field_to_attr(f)
1209 w_field_nonempty('Disabled')
1210 w_field_nonempty('AntiFeatures')
1211 w_field_nonempty('Provides')
1212 w_field_always('Categories')
1213 w_field_always('License')
1214 w_field_nonempty('Author Name')
1215 w_field_nonempty('Author Email')
1216 w_field_nonempty('Author Web Site')
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_nonempty('Summary')
1229 w_field_nonempty('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.versionName == "Ignore":
1246 w_comments('build:%s' % build.versionCode)
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.versionName, build.versionCode))
1290 for f in build_flags_order:
1296 if f == 'androidupdate':
1297 f = 'update' # avoid conflicting with Build(dict).update()
1298 mf.write(' %s=' % f)
1299 if t == TYPE_STRING:
1301 elif t == TYPE_BOOL:
1303 elif t == TYPE_SCRIPT:
1305 for s in v.split(' && '):
1309 mf.write(' && \\\n ')
1311 elif t == TYPE_LIST:
1312 mf.write(','.join(v))
1316 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1319 def write_metadata(metadatapath, app):
1320 _, ext = fdroidserver.common.get_extension(metadatapath)
1321 accepted = fdroidserver.common.config['accepted_formats']
1322 if ext not in accepted:
1323 warn_or_exception('Cannot write "%s", not an accepted format, use: %s'
1324 % (metadatapath, ', '.join(accepted)))
1326 with open(metadatapath, 'w', encoding='utf8') as mf:
1328 return write_txt(mf, app)
1330 return write_yaml(mf, app)
1331 warn_or_exception('Unknown metadata format: %s' % metadatapath)
1334 def add_metadata_arguments(parser):
1335 '''add common command line flags related to metadata processing'''
1336 parser.add_argument("-W", default='error',
1337 help="force errors to be warnings, or ignore")