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/>.
29 from collections import OrderedDict
30 # use libyaml if it is available
32 from yaml import CLoader
35 from yaml import Loader
38 import fdroidserver.common
39 from fdroidserver import _
40 from fdroidserver.exception import MetaDataException, FDroidException
43 warnings_action = None
46 def warn_or_exception(value):
47 '''output warning or Exception depending on -W'''
48 if warnings_action == 'ignore':
50 elif warnings_action == 'error':
51 raise MetaDataException(value)
53 logging.warning(value)
56 # 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 = ''
124 self.LiberapayID = None
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
154 def __getattr__(self, name):
158 raise AttributeError("No such attribute: " + name)
160 def __setattr__(self, name, value):
163 def __delattr__(self, name):
167 raise AttributeError("No such attribute: " + name)
169 def get_last_build(self):
170 if len(self.builds) > 0:
171 return self.builds[-1]
188 'Description': TYPE_MULTILINE,
189 'MaintainerNotes': TYPE_MULTILINE,
190 'Categories': TYPE_LIST,
191 'AntiFeatures': TYPE_LIST,
192 'BuildVersion': TYPE_BUILD,
193 'Build': TYPE_BUILD_V2,
194 'UseBuilt': TYPE_OBSOLETE,
199 name = name.replace(' ', '')
200 if name in fieldtypes:
201 return fieldtypes[name]
205 # In the order in which they are laid out on files
206 build_flags_order = [
240 # old .txt format has version name/code inline in the 'Build:' line
241 # but YAML and JSON have a explicit key for them
242 build_flags = ['versionName', 'versionCode'] + build_flags_order
247 def __init__(self, copydict=None):
252 self.submodules = False
258 self.buildozer = False
261 self.oldsdkloc = False
263 self.forceversion = False
264 self.forcevercode = False
268 self.androidupdate = []
275 self.preassemble = []
276 self.gradleprops = []
277 self.antcommands = []
278 self.novcheck = False
279 self.antifeatures = []
281 super().__init__(copydict)
284 def __getattr__(self, name):
288 raise AttributeError("No such attribute: " + name)
290 def __setattr__(self, name, value):
293 def __delattr__(self, name):
297 raise AttributeError("No such attribute: " + name)
299 def build_method(self):
300 for f in ['maven', 'gradle', 'buildozer']:
307 # like build_method, but prioritize output=
308 def output_method(self):
311 for f in ['maven', 'gradle', 'buildozer']:
319 version = 'r12b' # falls back to latest
320 paths = fdroidserver.common.config['ndk_paths']
321 if version not in paths:
323 return paths[version]
327 'versionCode': TYPE_INT,
328 'extlibs': TYPE_LIST,
329 'srclibs': TYPE_LIST,
332 'buildjni': TYPE_LIST,
333 'preassemble': TYPE_LIST,
334 'androidupdate': TYPE_LIST,
335 'scanignore': TYPE_LIST,
336 'scandelete': TYPE_LIST,
338 'antcommands': TYPE_LIST,
339 'gradleprops': TYPE_LIST,
342 'prebuild': TYPE_SCRIPT,
343 'build': TYPE_SCRIPT,
344 'submodules': TYPE_BOOL,
345 'oldsdkloc': TYPE_BOOL,
346 'forceversion': TYPE_BOOL,
347 'forcevercode': TYPE_BOOL,
348 'novcheck': TYPE_BOOL,
349 'antifeatures': TYPE_LIST,
354 if name in flagtypes:
355 return flagtypes[name]
359 class FieldValidator():
361 Designates App metadata field types and checks that it matches
363 'name' - The long name of the field type
364 'matching' - List of possible values or regex expression
365 'sep' - Separator to use if value may be a list
366 'fields' - Metadata fields (Field:Value) of this type
369 def __init__(self, name, matching, fields):
371 self.matching = matching
372 self.compiled = re.compile(matching)
375 def check(self, v, appid):
383 if not self.compiled.match(v):
384 warn_or_exception(_("'{value}' is not a valid {field} in {appid}. Regex pattern: {pattern}")
385 .format(value=v, field=self.name, appid=appid, pattern=self.matching))
388 # Generic value types
390 FieldValidator("Flattr ID",
394 FieldValidator("Liberapay ID",
398 FieldValidator("HTTP link",
400 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
402 FieldValidator("Email",
403 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
406 FieldValidator("Bitcoin address",
407 r'^[a-zA-Z0-9]{27,34}$',
410 FieldValidator("Litecoin address",
411 r'^L[a-zA-Z0-9]{33}$',
414 FieldValidator("Repo Type",
415 r'^(git|git-svn|svn|hg|bzr|srclib)$',
418 FieldValidator("Binaries",
422 FieldValidator("Archive Policy",
423 r'^[0-9]+ versions$',
426 FieldValidator("Anti-Feature",
427 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln|ApplicationDebuggable)$',
430 FieldValidator("Auto Update Mode",
431 r"^(Version .+|None)$",
434 FieldValidator("Update Check Mode",
435 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
440 # Check an app's metadata information for integrity errors
441 def check_metadata(app):
444 v.check(app[k], app.id)
447 # Formatter for descriptions. Create an instance, and call parseline() with
448 # each line of the description source from the metadata. At the end, call
449 # end() and then text_txt and text_html will contain the result.
450 class DescriptionFormatter:
457 def __init__(self, linkres):
460 self.state = self.stNONE
461 self.laststate = self.stNONE
464 self.html = io.StringIO()
465 self.text = io.StringIO()
467 self.linkResolver = None
468 self.linkResolver = linkres
470 def endcur(self, notstates=None):
471 if notstates and self.state in notstates:
473 if self.state == self.stPARA:
475 elif self.state == self.stUL:
477 elif self.state == self.stOL:
481 self.laststate = self.state
482 self.state = self.stNONE
483 whole_para = ' '.join(self.para_lines)
484 self.addtext(whole_para)
485 wrapped = textwrap.fill(whole_para, 80,
486 break_long_words=False,
487 break_on_hyphens=False)
488 self.text.write(wrapped)
489 self.html.write('</p>')
490 del self.para_lines[:]
493 self.html.write('</ul>')
494 self.laststate = self.state
495 self.state = self.stNONE
498 self.html.write('</ol>')
499 self.laststate = self.state
500 self.state = self.stNONE
502 def formatted(self, txt, htmlbody):
505 txt = html.escape(txt, quote=False)
507 index = txt.find("''")
512 if txt.startswith("'''"):
518 self.bold = not self.bold
526 self.ital = not self.ital
529 def linkify(self, txt):
533 index = txt.find("[")
535 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
536 res_plain += self.formatted(txt[:index], False)
537 res_html += self.formatted(txt[:index], True)
539 if txt.startswith("[["):
540 index = txt.find("]]")
542 warn_or_exception(_("Unterminated ]]"))
544 if self.linkResolver:
545 url, urltext = self.linkResolver(url)
548 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
550 txt = txt[index + 2:]
552 index = txt.find("]")
554 warn_or_exception(_("Unterminated ]"))
556 index2 = url.find(' ')
560 urltxt = url[index2 + 1:]
563 warn_or_exception(_("URL title is just the URL, use brackets: [URL]"))
564 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
567 res_plain += ' (' + url + ')'
568 txt = txt[index + 1:]
570 def addtext(self, txt):
571 p, h = self.linkify(txt)
574 def parseline(self, line):
577 elif line.startswith('* '):
578 self.endcur([self.stUL])
579 if self.state != self.stUL:
580 self.html.write('<ul>')
581 self.state = self.stUL
582 if self.laststate != self.stNONE:
583 self.text.write('\n\n')
585 self.text.write('\n')
586 self.text.write(line)
587 self.html.write('<li>')
588 self.addtext(line[1:])
589 self.html.write('</li>')
590 elif line.startswith('# '):
591 self.endcur([self.stOL])
592 if self.state != self.stOL:
593 self.html.write('<ol>')
594 self.state = self.stOL
595 if self.laststate != self.stNONE:
596 self.text.write('\n\n')
598 self.text.write('\n')
599 self.text.write(line)
600 self.html.write('<li>')
601 self.addtext(line[1:])
602 self.html.write('</li>')
604 self.para_lines.append(line)
605 self.endcur([self.stPARA])
606 if self.state == self.stNONE:
607 self.state = self.stPARA
608 if self.laststate != self.stNONE:
609 self.text.write('\n\n')
610 self.html.write('<p>')
614 self.text_txt = self.text.getvalue()
615 self.text_html = self.html.getvalue()
620 # Parse multiple lines of description as written in a metadata file, returning
621 # a single string in text format and wrapped to 80 columns.
622 def description_txt(s):
623 ps = DescriptionFormatter(None)
624 for line in s.splitlines():
630 # Parse multiple lines of description as written in a metadata file, returning
631 # a single string in wiki format. Used for the Maintainer Notes field as well,
632 # because it's the same format.
633 def description_wiki(s):
637 # Parse multiple lines of description as written in a metadata file, returning
638 # a single string in HTML format.
639 def description_html(s, linkres):
640 ps = DescriptionFormatter(linkres)
641 for line in s.splitlines():
647 def parse_srclib(metadatapath):
651 # Defaults for fields that come from metadata
652 thisinfo['Repo Type'] = ''
653 thisinfo['Repo'] = ''
654 thisinfo['Subdir'] = None
655 thisinfo['Prepare'] = None
657 if not os.path.exists(metadatapath):
660 metafile = open(metadatapath, "r", encoding='utf-8')
663 for line in metafile:
665 line = line.rstrip('\r\n')
666 if not line or line.startswith("#"):
670 f, v = line.split(':', 1)
672 warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
675 thisinfo[f] = v.split(',')
685 """Read all srclib metadata.
687 The information read will be accessible as metadata.srclibs, which is a
688 dictionary, keyed on srclib name, with the values each being a dictionary
689 in the same format as that returned by the parse_srclib function.
691 A MetaDataException is raised if there are any problems with the srclib
696 # They were already loaded
697 if srclibs is not None:
703 if not os.path.exists(srcdir):
706 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
707 srclibname = os.path.basename(metadatapath[:-4])
708 srclibs[srclibname] = parse_srclib(metadatapath)
711 def read_metadata(xref=True, check_vcs=[], refresh=True, sort_by_time=False):
712 """Return a list of App instances sorted newest first
714 This reads all of the metadata files in a 'data' repository, then
715 builds a list of App instances from those files. The list is
716 sorted based on creation time, newest first. Most of the time,
717 the newer files are the most interesting.
719 If there are multiple metadata files for a single appid, then the first
720 file that is parsed wins over all the others, and the rest throw an
721 exception. So the original .txt format is parsed first, at least until
722 newer formats stabilize.
724 check_vcs is the list of packageNames to check for .fdroid.yml in source
728 # Always read the srclibs before the apps, since they can use a srlib as
729 # their source repository.
734 for basedir in ('metadata', 'tmp'):
735 if not os.path.exists(basedir):
738 metadatafiles = (glob.glob(os.path.join('metadata', '*.txt'))
739 + glob.glob(os.path.join('metadata', '*.json'))
740 + glob.glob(os.path.join('metadata', '*.yml'))
741 + glob.glob('.fdroid.txt')
742 + glob.glob('.fdroid.json')
743 + glob.glob('.fdroid.yml'))
746 entries = ((os.stat(path).st_mtime, path) for path in metadatafiles)
748 for _ignored, path in sorted(entries, reverse=True):
749 metadatafiles.append(path)
751 # most things want the index alpha sorted for stability
752 metadatafiles = sorted(metadatafiles)
754 for metadatapath in metadatafiles:
755 if metadatapath == '.fdroid.txt':
756 warn_or_exception(_('.fdroid.txt is not supported! Convert to .fdroid.yml or .fdroid.json.'))
757 packageName, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
758 if packageName in apps:
759 warn_or_exception(_("Found multiple metadata files for {appid}")
760 .format(path=packageName))
761 app = parse_metadata(metadatapath, packageName in check_vcs, refresh)
766 # Parse all descriptions at load time, just to ensure cross-referencing
767 # errors are caught early rather than when they hit the build server.
770 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
771 warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
773 for appid, app in apps.items():
775 description_html(app.Description, linkres)
776 except MetaDataException as e:
777 warn_or_exception(_("Problem with description of {appid}: {error}")
778 .format(appid=appid, error=str(e)))
783 # Port legacy ';' separators
784 list_sep = re.compile(r'[,;]')
787 def split_list_values(s):
789 for v in re.split(list_sep, s):
799 def get_default_app_info(metadatapath=None):
800 if metadatapath is None:
803 appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
805 if appid == '.fdroid': # we have local metadata in the app's source
806 if os.path.exists('AndroidManifest.xml'):
807 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
809 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
810 for root, dirs, files in os.walk(os.getcwd()):
811 if 'build.gradle' in files:
812 p = os.path.join(root, 'build.gradle')
813 with open(p, 'rb') as f:
815 m = pattern.search(data)
817 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
818 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
820 if manifestroot is None:
821 warn_or_exception(_("Cannot find a packageName for {path}!")
822 .format(path=metadatapath))
823 appid = manifestroot.attrib['package']
826 app.metadatapath = metadatapath
827 if appid is not None:
833 def sorted_builds(builds):
834 return sorted(builds, key=lambda build: int(build.versionCode))
837 esc_newlines = re.compile(r'\\( |\n)')
840 def post_metadata_parse(app):
841 # TODO keep native types, convert only for .txt metadata
842 for k, v in app.items():
843 if type(v) in (float, int):
847 app['builds'] = app.pop('Builds')
849 if 'flavours' in app and app['flavours'] == [True]:
850 app['flavours'] = 'yes'
852 if isinstance(app.Categories, str):
853 app.Categories = [app.Categories]
854 elif app.Categories is None:
855 app.Categories = ['None']
857 app.Categories = [str(i) for i in app.Categories]
859 def _yaml_bool_unmapable(v):
860 return v in (True, False, [True], [False])
862 def _yaml_bool_unmap(v):
872 _bool_allowed = ('disable', 'maven', 'buildozer')
876 for build in app['builds']:
877 if not isinstance(build, Build):
879 for k, v in build.items():
881 if flagtype(k) == TYPE_LIST:
882 if _yaml_bool_unmapable(v):
883 build[k] = _yaml_bool_unmap(v)
885 if isinstance(v, str):
887 elif isinstance(v, bool):
892 elif flagtype(k) is TYPE_INT:
894 elif flagtype(k) is TYPE_STRING:
895 if isinstance(v, bool) and k in _bool_allowed:
898 if _yaml_bool_unmapable(v):
899 build[k] = _yaml_bool_unmap(v)
904 app.builds = sorted_builds(builds)
907 # Parse metadata for a single application.
909 # 'metadatapath' - the filename to read. The package id for the application comes
910 # from this filename. Pass None to get a blank entry.
912 # Returns a dictionary containing all the details of the application. There are
913 # two major kinds of information in the dictionary. Keys beginning with capital
914 # letters correspond directory to identically named keys in the metadata file.
915 # Keys beginning with lower case letters are generated in one way or another,
916 # and are not found verbatim in the metadata.
918 # Known keys not originating from the metadata are:
920 # 'builds' - a list of dictionaries containing build information
921 # for each defined build
922 # 'comments' - a list of comments from the metadata file. Each is
923 # a list of the form [field, comment] where field is
924 # the name of the field it preceded in the metadata
925 # file. Where field is None, the comment goes at the
926 # end of the file. Alternatively, 'build:version' is
927 # for a comment before a particular build version.
928 # 'descriptionlines' - original lines of description as formatted in the
933 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
934 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
938 if bool_true.match(s):
940 if bool_false.match(s):
942 warn_or_exception(_("Invalid boolean '%s'") % s)
945 def parse_metadata(metadatapath, check_vcs=False, refresh=True):
946 '''parse metadata file, optionally checking the git repo for metadata first'''
948 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
949 accepted = fdroidserver.common.config['accepted_formats']
950 if ext not in accepted:
951 warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
952 .format(path=metadatapath, formats=', '.join(accepted)))
955 app.metadatapath = metadatapath
956 name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
957 if name == '.fdroid':
962 with open(metadatapath, 'r', encoding='utf-8') as mf:
964 parse_txt_metadata(mf, app)
966 parse_json_metadata(mf, app)
968 parse_yaml_metadata(mf, app)
970 warn_or_exception(_('Unknown metadata format: {path}')
971 .format(path=metadatapath))
973 if check_vcs and app.Repo:
974 build_dir = fdroidserver.common.get_build_dir(app)
975 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
976 if not os.path.isfile(metadata_in_repo):
977 vcs, build_dir = fdroidserver.common.setup_vcs(app)
978 if isinstance(vcs, fdroidserver.common.vcs_git):
979 vcs.gotorevision('HEAD', refresh) # HEAD since we can't know where else to go
980 if os.path.isfile(metadata_in_repo):
981 logging.debug('Including metadata from ' + metadata_in_repo)
982 # do not include fields already provided by main metadata file
983 app_in_repo = parse_metadata(metadata_in_repo)
984 for k, v in app_in_repo.items():
988 post_metadata_parse(app)
992 build = app.builds[-1]
994 root_dir = build.subdir
997 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
998 _ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
1003 def parse_json_metadata(mf, app):
1005 # fdroid metadata is only strings and booleans, no floats or ints.
1006 # TODO create schema using https://pypi.python.org/pypi/jsonschema
1007 jsoninfo = json.load(mf, parse_int=lambda s: s,
1008 parse_float=lambda s: s)
1009 app.update(jsoninfo)
1010 for f in ['Description', 'Maintainer Notes']:
1013 app[f] = '\n'.join(v)
1017 def parse_yaml_metadata(mf, app):
1018 yamldata = yaml.load(mf, Loader=YamlLoader)
1020 app.update(yamldata)
1024 def write_yaml(mf, app):
1026 # import rumael.yaml and check version
1029 except ImportError as e:
1030 raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
1031 if not ruamel.yaml.__version__:
1032 raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
1033 m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
1034 ruamel.yaml.__version__)
1036 raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
1037 if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
1038 raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
1039 # suiteable version ruamel.yaml imported successfully
1041 _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
1042 'true', 'True', 'TRUE',
1044 _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
1045 'false', 'False', 'FALSE',
1046 'off', 'Off', 'OFF')
1047 _yaml_bools_plus_lists = []
1048 _yaml_bools_plus_lists.extend(_yaml_bools_true)
1049 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1050 _yaml_bools_plus_lists.extend(_yaml_bools_false)
1051 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1053 def _class_as_dict_representer(dumper, data):
1054 '''Creates a YAML representation of a App/Build instance'''
1055 return dumper.represent_dict(data)
1057 def _field_to_yaml(typ, value):
1058 if typ is TYPE_STRING:
1059 if value in _yaml_bools_plus_lists:
1060 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1062 elif typ is TYPE_INT:
1064 elif typ is TYPE_MULTILINE:
1066 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1069 elif typ is TYPE_SCRIPT:
1071 return ruamel.yaml.scalarstring.preserve_literal(value)
1077 def _app_to_yaml(app):
1078 cm = ruamel.yaml.comments.CommentedMap()
1079 insert_newline = False
1080 for field in yaml_app_field_order:
1082 # next iteration will need to insert a newline
1083 insert_newline = True
1085 if app.get(field) or field is 'Builds':
1086 # .txt calls it 'builds' internally, everywhere else its 'Builds'
1087 if field is 'Builds':
1088 if app.get('builds'):
1089 cm.update({field: _builds_to_yaml(app)})
1090 elif field is 'CurrentVersionCode':
1091 cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1093 cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1096 # we need to prepend a newline in front of this field
1097 insert_newline = False
1098 # inserting empty lines is not supported so we add a
1099 # bogus comment and over-write its value
1100 cm.yaml_set_comment_before_after_key(field, 'bogus')
1101 cm.ca.items[field][1][-1].value = '\n'
1104 def _builds_to_yaml(app):
1105 fields = ['versionName', 'versionCode']
1106 fields.extend(build_flags_order)
1107 builds = ruamel.yaml.comments.CommentedSeq()
1108 for build in app.builds:
1109 b = ruamel.yaml.comments.CommentedMap()
1110 for field in fields:
1111 if hasattr(build, field) and getattr(build, field):
1112 value = getattr(build, field)
1113 if field == 'gradle' and value == ['off']:
1114 value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1115 if field in ('disable', 'maven', 'buildozer'):
1118 elif value == 'yes':
1120 b.update({field: _field_to_yaml(flagtype(field), value)})
1123 # insert extra empty lines between build entries
1124 for i in range(1, len(builds)):
1125 builds.yaml_set_comment_before_after_key(i, 'bogus')
1126 builds.ca.items[i][1][-1].value = '\n'
1130 yaml_app_field_order = [
1167 'UpdateCheckIgnore',
1172 'CurrentVersionCode',
1177 yaml_app = _app_to_yaml(app)
1178 ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1181 build_line_sep = re.compile(r'(?<!\\),')
1182 build_cont = re.compile(r'^[ \t]')
1185 def parse_txt_metadata(mf, app):
1189 def add_buildflag(p, build):
1191 warn_or_exception(_("Empty build flag at {linedesc}")
1192 .format(linedesc=linedesc))
1193 bv = p.split('=', 1)
1195 warn_or_exception(_("Invalid build flag at {line} in {linedesc}")
1196 .format(line=buildlines[0], linedesc=linedesc))
1201 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
1204 pv = split_list_values(pv)
1206 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1208 elif t == TYPE_BOOL:
1209 build[pk] = _decode_bool(pv)
1211 def parse_buildline(lines):
1213 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1215 warn_or_exception(_("Invalid build format: {value} in {name}")
1216 .format(value=v, name=mf.name))
1218 build.versionName = parts[0]
1219 build.versionCode = parts[1]
1220 check_versionCode(build.versionCode)
1222 if parts[2].startswith('!'):
1223 # For backwards compatibility, handle old-style disabling,
1224 # including attempting to extract the commit from the message
1225 build.disable = parts[2][1:]
1226 commit = 'unknown - see disabled'
1227 index = parts[2].rfind('at ')
1229 commit = parts[2][index + 3:]
1230 if commit.endswith(')'):
1231 commit = commit[:-1]
1232 build.commit = commit
1234 build.commit = parts[2]
1236 add_buildflag(p, build)
1240 def check_versionCode(versionCode):
1244 warn_or_exception(_('Invalid versionCode: "{versionCode}" is not an integer!')
1245 .format(versionCode=versionCode))
1247 def add_comments(key):
1250 app.comments[key] = list(curcomments)
1255 multiline_lines = []
1265 linedesc = "%s:%d" % (mf.name, c)
1266 line = line.rstrip('\r\n')
1268 if build_cont.match(line):
1269 if line.endswith('\\'):
1270 buildlines.append(line[:-1].lstrip())
1272 buildlines.append(line.lstrip())
1273 bl = ''.join(buildlines)
1274 add_buildflag(bl, build)
1277 if not build.commit and not build.disable:
1278 warn_or_exception(_("No commit specified for {versionName} in {linedesc}")
1279 .format(versionName=build.versionName, linedesc=linedesc))
1281 app.builds.append(build)
1282 add_comments('build:' + build.versionCode)
1288 if line.startswith("#"):
1289 curcomments.append(line[1:].strip())
1292 f, v = line.split(':', 1)
1294 warn_or_exception(_("Invalid metadata in: ") + linedesc)
1296 if f not in app_fields:
1297 warn_or_exception(_('Unrecognised app field: ') + f)
1299 # Translate obsolete fields...
1300 if f == 'Market Version':
1301 f = 'Current Version'
1302 if f == 'Market Version Code':
1303 f = 'Current Version Code'
1305 f = f.replace(' ', '')
1307 ftype = fieldtype(f)
1308 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1310 if ftype == TYPE_MULTILINE:
1313 warn_or_exception(_("Unexpected text on same line as {field} in {linedesc}")
1314 .format(field=f, linedesc=linedesc))
1315 elif ftype == TYPE_STRING:
1317 elif ftype == TYPE_LIST:
1318 app[f] = split_list_values(v)
1319 elif ftype == TYPE_BUILD:
1320 if v.endswith("\\"):
1323 buildlines.append(v[:-1])
1325 build = parse_buildline([v])
1326 app.builds.append(build)
1327 add_comments('build:' + app.builds[-1].versionCode)
1328 elif ftype == TYPE_BUILD_V2:
1331 warn_or_exception(_('Build should have comma-separated '
1332 'versionName and versionCode, '
1333 'not "{value}", in {linedesc}')
1334 .format(value=v, linedesc=linedesc))
1336 build.versionName = vv[0]
1337 build.versionCode = vv[1]
1338 check_versionCode(build.versionCode)
1340 if build.versionCode in vc_seen:
1341 warn_or_exception(_('Duplicate build recipe found for versionCode {versionCode} in {linedesc}')
1342 .format(versionCode=build.versionCode, linedesc=linedesc))
1343 vc_seen.add(build.versionCode)
1346 elif ftype == TYPE_OBSOLETE:
1347 pass # Just throw it away!
1349 warn_or_exception(_("Unrecognised field '{field}' in {linedesc}")
1350 .format(field=f, linedesc=linedesc))
1351 elif mode == 1: # Multiline field
1354 app[f] = '\n'.join(multiline_lines)
1355 del multiline_lines[:]
1357 multiline_lines.append(line)
1358 elif mode == 2: # Line continuation mode in Build Version
1359 if line.endswith("\\"):
1360 buildlines.append(line[:-1])
1362 buildlines.append(line)
1363 build = parse_buildline(buildlines)
1364 app.builds.append(build)
1365 add_comments('build:' + app.builds[-1].versionCode)
1369 # Mode at end of file should always be 0
1371 warn_or_exception(_("{field} not terminated in {name}")
1372 .format(field=f, name=mf.name))
1374 warn_or_exception(_("Unterminated continuation in {name}")
1375 .format(name=mf.name))
1377 warn_or_exception(_("Unterminated build in {name}")
1378 .format(name=mf.name))
1383 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1385 def field_to_attr(f):
1387 Translates human-readable field names to attribute names, e.g.
1388 'Auto Name' to 'AutoName'
1390 return f.replace(' ', '')
1392 def attr_to_field(k):
1394 Translates attribute names to human-readable field names, e.g.
1395 'AutoName' to 'Auto Name'
1399 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1402 def w_comments(key):
1403 if key not in app.comments:
1405 for line in app.comments[key]:
1408 def w_field_always(f, v=None):
1409 key = field_to_attr(f)
1415 def w_field_nonempty(f, v=None):
1416 key = field_to_attr(f)
1423 w_field_nonempty('Disabled')
1424 w_field_nonempty('AntiFeatures')
1425 w_field_nonempty('Provides')
1426 w_field_always('Categories')
1427 w_field_always('License')
1428 w_field_nonempty('Author Name')
1429 w_field_nonempty('Author Email')
1430 w_field_nonempty('Author Web Site')
1431 w_field_always('Web Site')
1432 w_field_always('Source Code')
1433 w_field_always('Issue Tracker')
1434 w_field_nonempty('Changelog')
1435 w_field_nonempty('Donate')
1436 w_field_nonempty('FlattrID')
1437 w_field_nonempty('LiberapayID')
1438 w_field_nonempty('Bitcoin')
1439 w_field_nonempty('Litecoin')
1441 w_field_nonempty('Name')
1442 w_field_nonempty('Auto Name')
1443 w_field_nonempty('Summary')
1444 w_field_nonempty('Description', description_txt(app.Description))
1446 if app.RequiresRoot:
1447 w_field_always('Requires Root', 'yes')
1450 w_field_always('Repo Type')
1451 w_field_always('Repo')
1453 w_field_always('Binaries')
1456 for build in app.builds:
1458 if build.versionName == "Ignore":
1461 w_comments('build:%s' % build.versionCode)
1465 if app.MaintainerNotes:
1466 w_field_always('Maintainer Notes', app.MaintainerNotes)
1469 w_field_nonempty('Archive Policy')
1470 w_field_always('Auto Update Mode')
1471 w_field_always('Update Check Mode')
1472 w_field_nonempty('Update Check Ignore')
1473 w_field_nonempty('Vercode Operation')
1474 w_field_nonempty('Update Check Name')
1475 w_field_nonempty('Update Check Data')
1476 if app.CurrentVersion:
1477 w_field_always('Current Version')
1478 w_field_always('Current Version Code')
1479 if app.NoSourceSince:
1481 w_field_always('No Source Since')
1485 # Write a metadata file in txt format.
1487 # 'mf' - Writer interface (file, StringIO, ...)
1488 # 'app' - The app data
1489 def write_txt(mf, app):
1491 def w_comment(line):
1492 mf.write("# %s\n" % line)
1498 elif t == TYPE_MULTILINE:
1499 v = '\n' + v + '\n.'
1500 mf.write("%s:%s\n" % (f, v))
1503 mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1505 for f in build_flags_order:
1511 if f == 'androidupdate':
1512 f = 'update' # avoid conflicting with Build(dict).update()
1513 mf.write(' %s=' % f)
1514 if t == TYPE_STRING:
1516 elif t == TYPE_BOOL:
1518 elif t == TYPE_SCRIPT:
1520 for s in v.split(' && '):
1524 mf.write(' && \\\n ')
1526 elif t == TYPE_LIST:
1527 mf.write(','.join(v))
1531 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1534 def write_metadata(metadatapath, app):
1535 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
1536 accepted = fdroidserver.common.config['accepted_formats']
1537 if ext not in accepted:
1538 warn_or_exception(_('Cannot write "{path}", not an accepted format, use: {formats}')
1539 .format(path=metadatapath, formats=', '.join(accepted)))
1542 with open(metadatapath, 'w', encoding='utf8') as mf:
1544 return write_txt(mf, app)
1546 return write_yaml(mf, app)
1547 except FDroidException as e:
1548 os.remove(metadatapath)
1551 warn_or_exception(_('Unknown metadata format: %s') % metadatapath)
1554 def add_metadata_arguments(parser):
1555 '''add common command line flags related to metadata processing'''
1556 parser.add_argument("-W", choices=['error', 'warn', 'ignore'], default='error',
1557 help=_("force metadata errors (default) to be warnings, or to be ignored."))