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 = [
241 # old .txt format has version name/code inline in the 'Build:' line
242 # but YAML and JSON have a explicit key for them
243 build_flags = ['versionName', 'versionCode'] + build_flags_order
248 def __init__(self, copydict=None):
254 self.submodules = False
260 self.buildozer = False
263 self.oldsdkloc = False
265 self.forceversion = False
266 self.forcevercode = False
270 self.androidupdate = []
277 self.preassemble = []
278 self.gradleprops = []
279 self.antcommands = []
280 self.novcheck = False
281 self.antifeatures = []
283 super().__init__(copydict)
286 def __getattr__(self, name):
290 raise AttributeError("No such attribute: " + name)
292 def __setattr__(self, name, value):
295 def __delattr__(self, name):
299 raise AttributeError("No such attribute: " + name)
301 def build_method(self):
302 for f in ['maven', 'gradle', 'buildozer']:
309 # like build_method, but prioritize output=
310 def output_method(self):
313 for f in ['maven', 'gradle', 'buildozer']:
321 version = 'r12b' # falls back to latest
322 paths = fdroidserver.common.config['ndk_paths']
323 if version not in paths:
325 return paths[version]
329 'versionCode': TYPE_INT,
330 'extlibs': TYPE_LIST,
331 'srclibs': TYPE_LIST,
334 'buildjni': TYPE_LIST,
335 'preassemble': TYPE_LIST,
336 'androidupdate': TYPE_LIST,
337 'scanignore': TYPE_LIST,
338 'scandelete': TYPE_LIST,
340 'antcommands': TYPE_LIST,
341 'gradleprops': TYPE_LIST,
344 'prebuild': TYPE_SCRIPT,
345 'build': TYPE_SCRIPT,
346 'submodules': TYPE_BOOL,
347 'oldsdkloc': TYPE_BOOL,
348 'forceversion': TYPE_BOOL,
349 'forcevercode': TYPE_BOOL,
350 'novcheck': TYPE_BOOL,
351 'antifeatures': TYPE_LIST,
357 if name in flagtypes:
358 return flagtypes[name]
362 class FieldValidator():
364 Designates App metadata field types and checks that it matches
366 'name' - The long name of the field type
367 'matching' - List of possible values or regex expression
368 'sep' - Separator to use if value may be a list
369 'fields' - Metadata fields (Field:Value) of this type
372 def __init__(self, name, matching, fields):
374 self.matching = matching
375 self.compiled = re.compile(matching)
378 def check(self, v, appid):
386 if not self.compiled.match(v):
387 warn_or_exception(_("'{value}' is not a valid {field} in {appid}. Regex pattern: {pattern}")
388 .format(value=v, field=self.name, appid=appid, pattern=self.matching))
391 # Generic value types
393 FieldValidator("Flattr ID",
397 FieldValidator("Liberapay ID",
401 FieldValidator("HTTP link",
403 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
405 FieldValidator("Email",
406 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
409 FieldValidator("Bitcoin address",
410 r'^[a-zA-Z0-9]{27,34}$',
413 FieldValidator("Litecoin address",
414 r'^L[a-zA-Z0-9]{33}$',
417 FieldValidator("Repo Type",
418 r'^(git|git-svn|svn|hg|bzr|srclib)$',
421 FieldValidator("Binaries",
425 FieldValidator("Archive Policy",
426 r'^[0-9]+ versions$',
429 FieldValidator("Anti-Feature",
430 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln|ApplicationDebuggable)$',
433 FieldValidator("Auto Update Mode",
434 r"^(Version .+|None)$",
437 FieldValidator("Update Check Mode",
438 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
443 # Check an app's metadata information for integrity errors
444 def check_metadata(app):
447 v.check(app[k], app.id)
450 # Formatter for descriptions. Create an instance, and call parseline() with
451 # each line of the description source from the metadata. At the end, call
452 # end() and then text_txt and text_html will contain the result.
453 class DescriptionFormatter:
460 def __init__(self, linkres):
463 self.state = self.stNONE
464 self.laststate = self.stNONE
467 self.html = io.StringIO()
468 self.text = io.StringIO()
470 self.linkResolver = None
471 self.linkResolver = linkres
473 def endcur(self, notstates=None):
474 if notstates and self.state in notstates:
476 if self.state == self.stPARA:
478 elif self.state == self.stUL:
480 elif self.state == self.stOL:
484 self.laststate = self.state
485 self.state = self.stNONE
486 whole_para = ' '.join(self.para_lines)
487 self.addtext(whole_para)
488 wrapped = textwrap.fill(whole_para, 80,
489 break_long_words=False,
490 break_on_hyphens=False)
491 self.text.write(wrapped)
492 self.html.write('</p>')
493 del self.para_lines[:]
496 self.html.write('</ul>')
497 self.laststate = self.state
498 self.state = self.stNONE
501 self.html.write('</ol>')
502 self.laststate = self.state
503 self.state = self.stNONE
505 def formatted(self, txt, htmlbody):
508 txt = html.escape(txt, quote=False)
510 index = txt.find("''")
515 if txt.startswith("'''"):
521 self.bold = not self.bold
529 self.ital = not self.ital
532 def linkify(self, txt):
536 index = txt.find("[")
538 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
539 res_plain += self.formatted(txt[:index], False)
540 res_html += self.formatted(txt[:index], True)
542 if txt.startswith("[["):
543 index = txt.find("]]")
545 warn_or_exception(_("Unterminated ]]"))
547 if self.linkResolver:
548 url, urltext = self.linkResolver(url)
551 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
553 txt = txt[index + 2:]
555 index = txt.find("]")
557 warn_or_exception(_("Unterminated ]"))
559 index2 = url.find(' ')
563 urltxt = url[index2 + 1:]
566 warn_or_exception(_("URL title is just the URL, use brackets: [URL]"))
567 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
570 res_plain += ' (' + url + ')'
571 txt = txt[index + 1:]
573 def addtext(self, txt):
574 p, h = self.linkify(txt)
577 def parseline(self, line):
580 elif line.startswith('* '):
581 self.endcur([self.stUL])
582 if self.state != self.stUL:
583 self.html.write('<ul>')
584 self.state = self.stUL
585 if self.laststate != self.stNONE:
586 self.text.write('\n\n')
588 self.text.write('\n')
589 self.text.write(line)
590 self.html.write('<li>')
591 self.addtext(line[1:])
592 self.html.write('</li>')
593 elif line.startswith('# '):
594 self.endcur([self.stOL])
595 if self.state != self.stOL:
596 self.html.write('<ol>')
597 self.state = self.stOL
598 if self.laststate != self.stNONE:
599 self.text.write('\n\n')
601 self.text.write('\n')
602 self.text.write(line)
603 self.html.write('<li>')
604 self.addtext(line[1:])
605 self.html.write('</li>')
607 self.para_lines.append(line)
608 self.endcur([self.stPARA])
609 if self.state == self.stNONE:
610 self.state = self.stPARA
611 if self.laststate != self.stNONE:
612 self.text.write('\n\n')
613 self.html.write('<p>')
617 self.text_txt = self.text.getvalue()
618 self.text_html = self.html.getvalue()
623 # Parse multiple lines of description as written in a metadata file, returning
624 # a single string in text format and wrapped to 80 columns.
625 def description_txt(s):
626 ps = DescriptionFormatter(None)
627 for line in s.splitlines():
633 # Parse multiple lines of description as written in a metadata file, returning
634 # a single string in wiki format. Used for the Maintainer Notes field as well,
635 # because it's the same format.
636 def description_wiki(s):
640 # Parse multiple lines of description as written in a metadata file, returning
641 # a single string in HTML format.
642 def description_html(s, linkres):
643 ps = DescriptionFormatter(linkres)
644 for line in s.splitlines():
650 def parse_srclib(metadatapath):
654 # Defaults for fields that come from metadata
655 thisinfo['Repo Type'] = ''
656 thisinfo['Repo'] = ''
657 thisinfo['Subdir'] = None
658 thisinfo['Prepare'] = None
660 if not os.path.exists(metadatapath):
663 metafile = open(metadatapath, "r", encoding='utf-8')
666 for line in metafile:
668 line = line.rstrip('\r\n')
669 if not line or line.startswith("#"):
673 f, v = line.split(':', 1)
675 warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
678 thisinfo[f] = v.split(',')
688 """Read all srclib metadata.
690 The information read will be accessible as metadata.srclibs, which is a
691 dictionary, keyed on srclib name, with the values each being a dictionary
692 in the same format as that returned by the parse_srclib function.
694 A MetaDataException is raised if there are any problems with the srclib
699 # They were already loaded
700 if srclibs is not None:
706 if not os.path.exists(srcdir):
709 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
710 srclibname = os.path.basename(metadatapath[:-4])
711 srclibs[srclibname] = parse_srclib(metadatapath)
714 def read_metadata(xref=True, check_vcs=[], refresh=True, sort_by_time=False):
715 """Return a list of App instances sorted newest first
717 This reads all of the metadata files in a 'data' repository, then
718 builds a list of App instances from those files. The list is
719 sorted based on creation time, newest first. Most of the time,
720 the newer files are the most interesting.
722 If there are multiple metadata files for a single appid, then the first
723 file that is parsed wins over all the others, and the rest throw an
724 exception. So the original .txt format is parsed first, at least until
725 newer formats stabilize.
727 check_vcs is the list of appids to check for .fdroid.yml in source
731 # Always read the srclibs before the apps, since they can use a srlib as
732 # their source repository.
737 for basedir in ('metadata', 'tmp'):
738 if not os.path.exists(basedir):
741 metadatafiles = (glob.glob(os.path.join('metadata', '*.txt'))
742 + glob.glob(os.path.join('metadata', '*.json'))
743 + glob.glob(os.path.join('metadata', '*.yml'))
744 + glob.glob('.fdroid.txt')
745 + glob.glob('.fdroid.json')
746 + glob.glob('.fdroid.yml'))
749 entries = ((os.stat(path).st_mtime, path) for path in metadatafiles)
751 for _ignored, path in sorted(entries, reverse=True):
752 metadatafiles.append(path)
754 # most things want the index alpha sorted for stability
755 metadatafiles = sorted(metadatafiles)
757 for metadatapath in metadatafiles:
758 if metadatapath == '.fdroid.txt':
759 warn_or_exception(_('.fdroid.txt is not supported! Convert to .fdroid.yml or .fdroid.json.'))
760 appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
762 warn_or_exception(_("Found multiple metadata files for {appid}")
763 .format(appid=appid))
764 app = parse_metadata(metadatapath, appid in check_vcs, refresh)
769 # Parse all descriptions at load time, just to ensure cross-referencing
770 # errors are caught early rather than when they hit the build server.
773 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
774 warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
776 for appid, app in apps.items():
778 description_html(app.Description, linkres)
779 except MetaDataException as e:
780 warn_or_exception(_("Problem with description of {appid}: {error}")
781 .format(appid=appid, error=str(e)))
786 # Port legacy ';' separators
787 list_sep = re.compile(r'[,;]')
790 def split_list_values(s):
792 for v in re.split(list_sep, s):
802 def get_default_app_info(metadatapath=None):
803 if metadatapath is None:
806 appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
808 if appid == '.fdroid': # we have local metadata in the app's source
809 if os.path.exists('AndroidManifest.xml'):
810 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
812 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
813 for root, dirs, files in os.walk(os.getcwd()):
814 if 'build.gradle' in files:
815 p = os.path.join(root, 'build.gradle')
816 with open(p, 'rb') as f:
818 m = pattern.search(data)
820 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
821 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
823 if manifestroot is None:
824 warn_or_exception(_("Cannot find an appid for {path}!")
825 .format(path=metadatapath))
826 appid = manifestroot.attrib['package']
829 app.metadatapath = metadatapath
830 if appid is not None:
836 def sorted_builds(builds):
837 return sorted(builds, key=lambda build: int(build.versionCode))
840 esc_newlines = re.compile(r'\\( |\n)')
843 def post_metadata_parse(app):
844 # TODO keep native types, convert only for .txt metadata
845 for k, v in app.items():
846 if type(v) in (float, int):
850 app['builds'] = app.pop('Builds')
852 if 'flavours' in app and app['flavours'] == [True]:
853 app['flavours'] = 'yes'
855 if isinstance(app.Categories, str):
856 app.Categories = [app.Categories]
857 elif app.Categories is None:
858 app.Categories = ['None']
860 app.Categories = [str(i) for i in app.Categories]
862 def _yaml_bool_unmapable(v):
863 return v in (True, False, [True], [False])
865 def _yaml_bool_unmap(v):
875 _bool_allowed = ('disable', 'maven', 'buildozer')
879 for build in app['builds']:
880 if not isinstance(build, Build):
882 for k, v in build.items():
884 if flagtype(k) == TYPE_LIST:
885 if _yaml_bool_unmapable(v):
886 build[k] = _yaml_bool_unmap(v)
888 if isinstance(v, str):
890 elif isinstance(v, bool):
895 elif flagtype(k) is TYPE_INT:
897 elif flagtype(k) is TYPE_STRING:
898 if isinstance(v, bool) and k in _bool_allowed:
901 if _yaml_bool_unmapable(v):
902 build[k] = _yaml_bool_unmap(v)
907 app.builds = sorted_builds(builds)
910 # Parse metadata for a single application.
912 # 'metadatapath' - the filename to read. The "Application ID" aka
913 # "Package Name" for the application comes from this
914 # filename. Pass None to get a blank entry.
916 # Returns a dictionary containing all the details of the application. There are
917 # two major kinds of information in the dictionary. Keys beginning with capital
918 # letters correspond directory to identically named keys in the metadata file.
919 # Keys beginning with lower case letters are generated in one way or another,
920 # and are not found verbatim in the metadata.
922 # Known keys not originating from the metadata are:
924 # 'builds' - a list of dictionaries containing build information
925 # for each defined build
926 # 'comments' - a list of comments from the metadata file. Each is
927 # a list of the form [field, comment] where field is
928 # the name of the field it preceded in the metadata
929 # file. Where field is None, the comment goes at the
930 # end of the file. Alternatively, 'build:version' is
931 # for a comment before a particular build version.
932 # 'descriptionlines' - original lines of description as formatted in the
937 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
938 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
942 if bool_true.match(s):
944 if bool_false.match(s):
946 warn_or_exception(_("Invalid boolean '%s'") % s)
949 def parse_metadata(metadatapath, check_vcs=False, refresh=True):
950 '''parse metadata file, optionally checking the git repo for metadata first'''
952 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
953 accepted = fdroidserver.common.config['accepted_formats']
954 if ext not in accepted:
955 warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
956 .format(path=metadatapath, formats=', '.join(accepted)))
959 app.metadatapath = metadatapath
960 name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
961 if name == '.fdroid':
966 with open(metadatapath, 'r', encoding='utf-8') as mf:
968 parse_txt_metadata(mf, app)
970 parse_json_metadata(mf, app)
972 parse_yaml_metadata(mf, app)
974 warn_or_exception(_('Unknown metadata format: {path}')
975 .format(path=metadatapath))
977 if check_vcs and app.Repo:
978 build_dir = fdroidserver.common.get_build_dir(app)
979 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
980 if not os.path.isfile(metadata_in_repo):
981 vcs, build_dir = fdroidserver.common.setup_vcs(app)
982 if isinstance(vcs, fdroidserver.common.vcs_git):
983 vcs.gotorevision('HEAD', refresh) # HEAD since we can't know where else to go
984 if os.path.isfile(metadata_in_repo):
985 logging.debug('Including metadata from ' + metadata_in_repo)
986 # do not include fields already provided by main metadata file
987 app_in_repo = parse_metadata(metadata_in_repo)
988 for k, v in app_in_repo.items():
992 post_metadata_parse(app)
996 build = app.builds[-1]
998 root_dir = build.subdir
1001 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
1002 _ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
1007 def parse_json_metadata(mf, app):
1009 # fdroid metadata is only strings and booleans, no floats or ints.
1010 # TODO create schema using https://pypi.python.org/pypi/jsonschema
1011 jsoninfo = json.load(mf, parse_int=lambda s: s,
1012 parse_float=lambda s: s)
1013 app.update(jsoninfo)
1014 for f in ['Description', 'Maintainer Notes']:
1017 app[f] = '\n'.join(v)
1021 def parse_yaml_metadata(mf, app):
1022 yamldata = yaml.load(mf, Loader=YamlLoader)
1024 app.update(yamldata)
1028 def write_yaml(mf, app):
1030 # import rumael.yaml and check version
1033 except ImportError as e:
1034 raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
1035 if not ruamel.yaml.__version__:
1036 raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
1037 m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
1038 ruamel.yaml.__version__)
1040 raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
1041 if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
1042 raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
1043 # suiteable version ruamel.yaml imported successfully
1045 _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
1046 'true', 'True', 'TRUE',
1048 _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
1049 'false', 'False', 'FALSE',
1050 'off', 'Off', 'OFF')
1051 _yaml_bools_plus_lists = []
1052 _yaml_bools_plus_lists.extend(_yaml_bools_true)
1053 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1054 _yaml_bools_plus_lists.extend(_yaml_bools_false)
1055 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1057 def _class_as_dict_representer(dumper, data):
1058 '''Creates a YAML representation of a App/Build instance'''
1059 return dumper.represent_dict(data)
1061 def _field_to_yaml(typ, value):
1062 if typ is TYPE_STRING:
1063 if value in _yaml_bools_plus_lists:
1064 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1066 elif typ is TYPE_INT:
1068 elif typ is TYPE_MULTILINE:
1070 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1073 elif typ is TYPE_SCRIPT:
1075 return ruamel.yaml.scalarstring.preserve_literal(value)
1081 def _app_to_yaml(app):
1082 cm = ruamel.yaml.comments.CommentedMap()
1083 insert_newline = False
1084 for field in yaml_app_field_order:
1086 # next iteration will need to insert a newline
1087 insert_newline = True
1089 if app.get(field) or field is 'Builds':
1090 # .txt calls it 'builds' internally, everywhere else its 'Builds'
1091 if field is 'Builds':
1092 if app.get('builds'):
1093 cm.update({field: _builds_to_yaml(app)})
1094 elif field is 'CurrentVersionCode':
1095 cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1097 cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1100 # we need to prepend a newline in front of this field
1101 insert_newline = False
1102 # inserting empty lines is not supported so we add a
1103 # bogus comment and over-write its value
1104 cm.yaml_set_comment_before_after_key(field, 'bogus')
1105 cm.ca.items[field][1][-1].value = '\n'
1108 def _builds_to_yaml(app):
1109 fields = ['versionName', 'versionCode']
1110 fields.extend(build_flags_order)
1111 builds = ruamel.yaml.comments.CommentedSeq()
1112 for build in app.builds:
1113 b = ruamel.yaml.comments.CommentedMap()
1114 for field in fields:
1115 if hasattr(build, field) and getattr(build, field):
1116 value = getattr(build, field)
1117 if field == 'gradle' and value == ['off']:
1118 value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1119 if field in ('disable', 'maven', 'buildozer'):
1122 elif value == 'yes':
1124 b.update({field: _field_to_yaml(flagtype(field), value)})
1127 # insert extra empty lines between build entries
1128 for i in range(1, len(builds)):
1129 builds.yaml_set_comment_before_after_key(i, 'bogus')
1130 builds.ca.items[i][1][-1].value = '\n'
1134 yaml_app_field_order = [
1171 'UpdateCheckIgnore',
1176 'CurrentVersionCode',
1181 yaml_app = _app_to_yaml(app)
1182 ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1185 build_line_sep = re.compile(r'(?<!\\),')
1186 build_cont = re.compile(r'^[ \t]')
1189 def parse_txt_metadata(mf, app):
1193 def add_buildflag(p, build):
1195 warn_or_exception(_("Empty build flag at {linedesc}")
1196 .format(linedesc=linedesc))
1197 bv = p.split('=', 1)
1199 warn_or_exception(_("Invalid build flag at {line} in {linedesc}")
1200 .format(line=buildlines[0], linedesc=linedesc))
1205 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
1208 pv = split_list_values(pv)
1210 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1212 elif t == TYPE_BOOL:
1213 build[pk] = _decode_bool(pv)
1217 def parse_buildline(lines):
1219 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1221 warn_or_exception(_("Invalid build format: {value} in {name}")
1222 .format(value=v, name=mf.name))
1224 build.versionName = parts[0]
1225 build.versionCode = parts[1]
1226 check_versionCode(build.versionCode)
1228 if parts[2].startswith('!'):
1229 # For backwards compatibility, handle old-style disabling,
1230 # including attempting to extract the commit from the message
1231 build.disable = parts[2][1:]
1232 commit = 'unknown - see disabled'
1233 index = parts[2].rfind('at ')
1235 commit = parts[2][index + 3:]
1236 if commit.endswith(')'):
1237 commit = commit[:-1]
1238 build.commit = commit
1240 build.commit = parts[2]
1242 add_buildflag(p, build)
1246 def check_versionCode(versionCode):
1250 warn_or_exception(_('Invalid versionCode: "{versionCode}" is not an integer!')
1251 .format(versionCode=versionCode))
1253 def add_comments(key):
1256 app.comments[key] = list(curcomments)
1261 multiline_lines = []
1271 linedesc = "%s:%d" % (mf.name, c)
1272 line = line.rstrip('\r\n')
1274 if build_cont.match(line):
1275 if line.endswith('\\'):
1276 buildlines.append(line[:-1].lstrip())
1278 buildlines.append(line.lstrip())
1279 bl = ''.join(buildlines)
1280 add_buildflag(bl, build)
1283 if not build.commit and not build.disable:
1284 warn_or_exception(_("No commit specified for {versionName} in {linedesc}")
1285 .format(versionName=build.versionName, linedesc=linedesc))
1287 app.builds.append(build)
1288 add_comments('build:' + build.versionCode)
1294 if line.startswith("#"):
1295 curcomments.append(line[1:].strip())
1298 f, v = line.split(':', 1)
1300 warn_or_exception(_("Invalid metadata in: ") + linedesc)
1302 if f not in app_fields:
1303 warn_or_exception(_('Unrecognised app field: ') + f)
1305 # Translate obsolete fields...
1306 if f == 'Market Version':
1307 f = 'Current Version'
1308 if f == 'Market Version Code':
1309 f = 'Current Version Code'
1311 f = f.replace(' ', '')
1313 ftype = fieldtype(f)
1314 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1316 if ftype == TYPE_MULTILINE:
1319 warn_or_exception(_("Unexpected text on same line as {field} in {linedesc}")
1320 .format(field=f, linedesc=linedesc))
1321 elif ftype == TYPE_STRING:
1323 elif ftype == TYPE_LIST:
1324 app[f] = split_list_values(v)
1325 elif ftype == TYPE_BUILD:
1326 if v.endswith("\\"):
1329 buildlines.append(v[:-1])
1331 build = parse_buildline([v])
1332 app.builds.append(build)
1333 add_comments('build:' + app.builds[-1].versionCode)
1334 elif ftype == TYPE_BUILD_V2:
1337 warn_or_exception(_('Build should have comma-separated '
1338 'versionName and versionCode, '
1339 'not "{value}", in {linedesc}')
1340 .format(value=v, linedesc=linedesc))
1342 build.versionName = vv[0]
1343 build.versionCode = vv[1]
1344 check_versionCode(build.versionCode)
1346 if build.versionCode in vc_seen:
1347 warn_or_exception(_('Duplicate build recipe found for versionCode {versionCode} in {linedesc}')
1348 .format(versionCode=build.versionCode, linedesc=linedesc))
1349 vc_seen.add(build.versionCode)
1352 elif ftype == TYPE_OBSOLETE:
1353 pass # Just throw it away!
1355 warn_or_exception(_("Unrecognised field '{field}' in {linedesc}")
1356 .format(field=f, linedesc=linedesc))
1357 elif mode == 1: # Multiline field
1360 app[f] = '\n'.join(multiline_lines)
1361 del multiline_lines[:]
1363 multiline_lines.append(line)
1364 elif mode == 2: # Line continuation mode in Build Version
1365 if line.endswith("\\"):
1366 buildlines.append(line[:-1])
1368 buildlines.append(line)
1369 build = parse_buildline(buildlines)
1370 app.builds.append(build)
1371 add_comments('build:' + app.builds[-1].versionCode)
1375 # Mode at end of file should always be 0
1377 warn_or_exception(_("{field} not terminated in {name}")
1378 .format(field=f, name=mf.name))
1380 warn_or_exception(_("Unterminated continuation in {name}")
1381 .format(name=mf.name))
1383 warn_or_exception(_("Unterminated build in {name}")
1384 .format(name=mf.name))
1389 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1391 def field_to_attr(f):
1393 Translates human-readable field names to attribute names, e.g.
1394 'Auto Name' to 'AutoName'
1396 return f.replace(' ', '')
1398 def attr_to_field(k):
1400 Translates attribute names to human-readable field names, e.g.
1401 'AutoName' to 'Auto Name'
1405 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1408 def w_comments(key):
1409 if key not in app.comments:
1411 for line in app.comments[key]:
1414 def w_field_always(f, v=None):
1415 key = field_to_attr(f)
1421 def w_field_nonempty(f, v=None):
1422 key = field_to_attr(f)
1429 w_field_nonempty('Disabled')
1430 w_field_nonempty('AntiFeatures')
1431 w_field_nonempty('Provides')
1432 w_field_always('Categories')
1433 w_field_always('License')
1434 w_field_nonempty('Author Name')
1435 w_field_nonempty('Author Email')
1436 w_field_nonempty('Author Web Site')
1437 w_field_always('Web Site')
1438 w_field_always('Source Code')
1439 w_field_always('Issue Tracker')
1440 w_field_nonempty('Changelog')
1441 w_field_nonempty('Donate')
1442 w_field_nonempty('FlattrID')
1443 w_field_nonempty('LiberapayID')
1444 w_field_nonempty('Bitcoin')
1445 w_field_nonempty('Litecoin')
1447 w_field_nonempty('Name')
1448 w_field_nonempty('Auto Name')
1449 w_field_nonempty('Summary')
1450 w_field_nonempty('Description', description_txt(app.Description))
1452 if app.RequiresRoot:
1453 w_field_always('Requires Root', 'yes')
1456 w_field_always('Repo Type')
1457 w_field_always('Repo')
1459 w_field_always('Binaries')
1462 for build in app.builds:
1464 if build.versionName == "Ignore":
1467 w_comments('build:%s' % build.versionCode)
1471 if app.MaintainerNotes:
1472 w_field_always('Maintainer Notes', app.MaintainerNotes)
1475 w_field_nonempty('Archive Policy')
1476 w_field_always('Auto Update Mode')
1477 w_field_always('Update Check Mode')
1478 w_field_nonempty('Update Check Ignore')
1479 w_field_nonempty('Vercode Operation')
1480 w_field_nonempty('Update Check Name')
1481 w_field_nonempty('Update Check Data')
1482 if app.CurrentVersion:
1483 w_field_always('Current Version')
1484 w_field_always('Current Version Code')
1485 if app.NoSourceSince:
1487 w_field_always('No Source Since')
1491 # Write a metadata file in txt format.
1493 # 'mf' - Writer interface (file, StringIO, ...)
1494 # 'app' - The app data
1495 def write_txt(mf, app):
1497 def w_comment(line):
1498 mf.write("# %s\n" % line)
1504 elif t == TYPE_MULTILINE:
1505 v = '\n' + v + '\n.'
1506 mf.write("%s:%s\n" % (f, v))
1509 mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1511 for f in build_flags_order:
1517 if f == 'androidupdate':
1518 f = 'update' # avoid conflicting with Build(dict).update()
1519 mf.write(' %s=' % f)
1520 if t == TYPE_STRING:
1522 elif t == TYPE_BOOL:
1524 elif t == TYPE_SCRIPT:
1526 for s in v.split(' && '):
1530 mf.write(' && \\\n ')
1532 elif t == TYPE_LIST:
1533 mf.write(','.join(v))
1537 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1540 def write_metadata(metadatapath, app):
1541 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
1542 accepted = fdroidserver.common.config['accepted_formats']
1543 if ext not in accepted:
1544 warn_or_exception(_('Cannot write "{path}", not an accepted format, use: {formats}')
1545 .format(path=metadatapath, formats=', '.join(accepted)))
1548 with open(metadatapath, 'w', encoding='utf8') as mf:
1550 return write_txt(mf, app)
1552 return write_yaml(mf, app)
1553 except FDroidException as e:
1554 os.remove(metadatapath)
1557 warn_or_exception(_('Unknown metadata format: %s') % metadatapath)
1560 def add_metadata_arguments(parser):
1561 '''add common command line flags related to metadata processing'''
1562 parser.add_argument("-W", choices=['error', 'warn', 'ignore'], default='error',
1563 help=_("force metadata errors (default) to be warnings, or to be ignored."))