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 # use libyaml if it is available
31 from yaml import CLoader
34 from yaml import Loader
37 import fdroidserver.common
38 from fdroidserver import _
39 from fdroidserver.exception import MetaDataException, FDroidException
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)
52 logging.warning(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]
185 'Description': TYPE_MULTILINE,
186 'MaintainerNotes': TYPE_MULTILINE,
187 'Categories': TYPE_LIST,
188 'AntiFeatures': TYPE_LIST,
189 'BuildVersion': TYPE_BUILD,
190 'Build': TYPE_BUILD_V2,
191 'UseBuilt': TYPE_OBSOLETE,
196 name = name.replace(' ', '')
197 if name in fieldtypes:
198 return fieldtypes[name]
202 # In the order in which they are laid out on files
203 build_flags_order = [
238 # old .txt format has version name/code inline in the 'Build:' line
239 # but YAML and JSON have a explicit key for them
240 build_flags = ['versionName', 'versionCode'] + build_flags_order
245 def __init__(self, copydict=None):
250 self.submodules = False
257 self.buildozer = False
260 self.oldsdkloc = False
262 self.forceversion = False
263 self.forcevercode = False
267 self.androidupdate = []
274 self.preassemble = []
275 self.gradleprops = []
276 self.antcommands = []
277 self.novcheck = False
278 self.antifeatures = []
280 super().__init__(copydict)
283 def __getattr__(self, name):
287 raise AttributeError("No such attribute: " + name)
289 def __setattr__(self, name, value):
292 def __delattr__(self, name):
296 raise AttributeError("No such attribute: " + name)
298 def build_method(self):
299 for f in ['maven', 'gradle', 'kivy', 'buildozer']:
306 # like build_method, but prioritize output=
307 def output_method(self):
310 for f in ['maven', 'gradle', 'kivy', 'buildozer']:
318 version = 'r12b' # falls back to latest
319 paths = fdroidserver.common.config['ndk_paths']
320 if version not in paths:
322 return paths[version]
326 'versionCode': TYPE_INT,
327 'extlibs': TYPE_LIST,
328 'srclibs': TYPE_LIST,
331 'buildjni': TYPE_LIST,
332 'preassemble': TYPE_LIST,
333 'androidupdate': TYPE_LIST,
334 'scanignore': TYPE_LIST,
335 'scandelete': TYPE_LIST,
337 'antcommands': TYPE_LIST,
338 'gradleprops': TYPE_LIST,
341 'prebuild': TYPE_SCRIPT,
342 'build': TYPE_SCRIPT,
343 'submodules': TYPE_BOOL,
344 'oldsdkloc': TYPE_BOOL,
345 'forceversion': TYPE_BOOL,
346 'forcevercode': TYPE_BOOL,
347 'novcheck': TYPE_BOOL,
348 'antifeatures': TYPE_LIST,
353 if name in flagtypes:
354 return flagtypes[name]
358 class FieldValidator():
360 Designates App metadata field types and checks that it matches
362 'name' - The long name of the field type
363 'matching' - List of possible values or regex expression
364 'sep' - Separator to use if value may be a list
365 'fields' - Metadata fields (Field:Value) of this type
368 def __init__(self, name, matching, fields):
370 self.matching = matching
371 self.compiled = re.compile(matching)
374 def check(self, v, appid):
382 if not self.compiled.match(v):
383 warn_or_exception(_("'{value}' is not a valid {field} in {appid}. Regex pattern: {pattern}")
384 .format(value=v, field=self.name, appid=appid, pattern=self.matching))
387 # Generic value types
389 FieldValidator("Flattr ID",
393 FieldValidator("HTTP link",
395 ["WebSite", "SourceCode", "IssueTracker", "Changelog", "Donate"]),
397 FieldValidator("Email",
398 r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$',
401 FieldValidator("Bitcoin address",
402 r'^[a-zA-Z0-9]{27,34}$',
405 FieldValidator("Litecoin address",
406 r'^L[a-zA-Z0-9]{33}$',
409 FieldValidator("Repo Type",
410 r'^(git|git-svn|svn|hg|bzr|srclib)$',
413 FieldValidator("Binaries",
417 FieldValidator("Archive Policy",
418 r'^[0-9]+ versions$',
421 FieldValidator("Anti-Feature",
422 r'^(Ads|Tracking|NonFreeNet|NonFreeDep|NonFreeAdd|UpstreamNonFree|NonFreeAssets|KnownVuln|ApplicationDebuggable)$',
425 FieldValidator("Auto Update Mode",
426 r"^(Version .+|None)$",
429 FieldValidator("Update Check Mode",
430 r"^(Tags|Tags .+|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$",
435 # Check an app's metadata information for integrity errors
436 def check_metadata(app):
439 v.check(app[k], app.id)
442 # Formatter for descriptions. Create an instance, and call parseline() with
443 # each line of the description source from the metadata. At the end, call
444 # end() and then text_txt and text_html will contain the result.
445 class DescriptionFormatter:
452 def __init__(self, linkres):
455 self.state = self.stNONE
456 self.laststate = self.stNONE
459 self.html = io.StringIO()
460 self.text = io.StringIO()
462 self.linkResolver = None
463 self.linkResolver = linkres
465 def endcur(self, notstates=None):
466 if notstates and self.state in notstates:
468 if self.state == self.stPARA:
470 elif self.state == self.stUL:
472 elif self.state == self.stOL:
476 self.laststate = self.state
477 self.state = self.stNONE
478 whole_para = ' '.join(self.para_lines)
479 self.addtext(whole_para)
480 wrapped = textwrap.fill(whole_para, 80,
481 break_long_words=False,
482 break_on_hyphens=False)
483 self.text.write(wrapped)
484 self.html.write('</p>')
485 del self.para_lines[:]
488 self.html.write('</ul>')
489 self.laststate = self.state
490 self.state = self.stNONE
493 self.html.write('</ol>')
494 self.laststate = self.state
495 self.state = self.stNONE
497 def formatted(self, txt, htmlbody):
500 txt = html.escape(txt, quote=False)
502 index = txt.find("''")
507 if txt.startswith("'''"):
513 self.bold = not self.bold
521 self.ital = not self.ital
524 def linkify(self, txt):
528 index = txt.find("[")
530 return (res_plain + self.formatted(txt, False), res_html + self.formatted(txt, True))
531 res_plain += self.formatted(txt[:index], False)
532 res_html += self.formatted(txt[:index], True)
534 if txt.startswith("[["):
535 index = txt.find("]]")
537 warn_or_exception(_("Unterminated ]]"))
539 if self.linkResolver:
540 url, urltext = self.linkResolver(url)
543 res_html += '<a href="' + url + '">' + html.escape(urltext, quote=False) + '</a>'
545 txt = txt[index + 2:]
547 index = txt.find("]")
549 warn_or_exception(_("Unterminated ]"))
551 index2 = url.find(' ')
555 urltxt = url[index2 + 1:]
558 warn_or_exception(_("URL title is just the URL, use brackets: [URL]"))
559 res_html += '<a href="' + url + '">' + html.escape(urltxt, quote=False) + '</a>'
562 res_plain += ' (' + url + ')'
563 txt = txt[index + 1:]
565 def addtext(self, txt):
566 p, h = self.linkify(txt)
569 def parseline(self, line):
572 elif line.startswith('* '):
573 self.endcur([self.stUL])
574 if self.state != self.stUL:
575 self.html.write('<ul>')
576 self.state = self.stUL
577 if self.laststate != self.stNONE:
578 self.text.write('\n\n')
580 self.text.write('\n')
581 self.text.write(line)
582 self.html.write('<li>')
583 self.addtext(line[1:])
584 self.html.write('</li>')
585 elif line.startswith('# '):
586 self.endcur([self.stOL])
587 if self.state != self.stOL:
588 self.html.write('<ol>')
589 self.state = self.stOL
590 if self.laststate != self.stNONE:
591 self.text.write('\n\n')
593 self.text.write('\n')
594 self.text.write(line)
595 self.html.write('<li>')
596 self.addtext(line[1:])
597 self.html.write('</li>')
599 self.para_lines.append(line)
600 self.endcur([self.stPARA])
601 if self.state == self.stNONE:
602 self.state = self.stPARA
603 if self.laststate != self.stNONE:
604 self.text.write('\n\n')
605 self.html.write('<p>')
609 self.text_txt = self.text.getvalue()
610 self.text_html = self.html.getvalue()
615 # Parse multiple lines of description as written in a metadata file, returning
616 # a single string in text format and wrapped to 80 columns.
617 def description_txt(s):
618 ps = DescriptionFormatter(None)
619 for line in s.splitlines():
625 # Parse multiple lines of description as written in a metadata file, returning
626 # a single string in wiki format. Used for the Maintainer Notes field as well,
627 # because it's the same format.
628 def description_wiki(s):
632 # Parse multiple lines of description as written in a metadata file, returning
633 # a single string in HTML format.
634 def description_html(s, linkres):
635 ps = DescriptionFormatter(linkres)
636 for line in s.splitlines():
642 def parse_srclib(metadatapath):
646 # Defaults for fields that come from metadata
647 thisinfo['Repo Type'] = ''
648 thisinfo['Repo'] = ''
649 thisinfo['Subdir'] = None
650 thisinfo['Prepare'] = None
652 if not os.path.exists(metadatapath):
655 metafile = open(metadatapath, "r", encoding='utf-8')
658 for line in metafile:
660 line = line.rstrip('\r\n')
661 if not line or line.startswith("#"):
665 f, v = line.split(':', 1)
667 warn_or_exception(_("Invalid metadata in %s:%d") % (line, n))
670 thisinfo[f] = v.split(',')
680 """Read all srclib metadata.
682 The information read will be accessible as metadata.srclibs, which is a
683 dictionary, keyed on srclib name, with the values each being a dictionary
684 in the same format as that returned by the parse_srclib function.
686 A MetaDataException is raised if there are any problems with the srclib
691 # They were already loaded
692 if srclibs is not None:
698 if not os.path.exists(srcdir):
701 for metadatapath in sorted(glob.glob(os.path.join(srcdir, '*.txt'))):
702 srclibname = os.path.basename(metadatapath[:-4])
703 srclibs[srclibname] = parse_srclib(metadatapath)
706 def read_metadata(xref=True, check_vcs=[]):
708 Read all metadata. Returns a list of 'app' objects (which are dictionaries as
709 returned by the parse_txt_metadata function.
711 check_vcs is the list of packageNames to check for .fdroid.yml in source
714 # Always read the srclibs before the apps, since they can use a srlib as
715 # their source repository.
720 for basedir in ('metadata', 'tmp'):
721 if not os.path.exists(basedir):
724 # If there are multiple metadata files for a single appid, then the first
725 # file that is parsed wins over all the others, and the rest throw an
726 # exception. So the original .txt format is parsed first, at least until
727 # newer formats stabilize.
729 for metadatapath in sorted(glob.glob(os.path.join('metadata', '*.txt'))
730 + glob.glob(os.path.join('metadata', '*.json'))
731 + glob.glob(os.path.join('metadata', '*.yml'))
732 + glob.glob('.fdroid.txt')
733 + glob.glob('.fdroid.json')
734 + glob.glob('.fdroid.yml')):
735 packageName, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
736 if packageName in apps:
737 warn_or_exception(_("Found multiple metadata files for {appid}")
738 .format(path=packageName))
739 app = parse_metadata(metadatapath, packageName in check_vcs)
744 # Parse all descriptions at load time, just to ensure cross-referencing
745 # errors are caught early rather than when they hit the build server.
748 return ("fdroid.app:" + appid, "Dummy name - don't know yet")
749 warn_or_exception(_("Cannot resolve app id {appid}").format(appid=appid))
751 for appid, app in apps.items():
753 description_html(app.Description, linkres)
754 except MetaDataException as e:
755 warn_or_exception(_("Problem with description of {appid}: {error}")
756 .format(appid=appid, error=str(e)))
761 # Port legacy ';' separators
762 list_sep = re.compile(r'[,;]')
765 def split_list_values(s):
767 for v in re.split(list_sep, s):
777 def get_default_app_info(metadatapath=None):
778 if metadatapath is None:
781 appid, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
783 if appid == '.fdroid': # we have local metadata in the app's source
784 if os.path.exists('AndroidManifest.xml'):
785 manifestroot = fdroidserver.common.parse_xml('AndroidManifest.xml')
787 pattern = re.compile(""".*manifest\.srcFile\s+'AndroidManifest\.xml'.*""")
788 for root, dirs, files in os.walk(os.getcwd()):
789 if 'build.gradle' in files:
790 p = os.path.join(root, 'build.gradle')
791 with open(p, 'rb') as f:
793 m = pattern.search(data)
795 logging.debug('Using: ' + os.path.join(root, 'AndroidManifest.xml'))
796 manifestroot = fdroidserver.common.parse_xml(os.path.join(root, 'AndroidManifest.xml'))
798 if manifestroot is None:
799 warn_or_exception(_("Cannot find a packageName for {path}!")
800 .format(path=metadatapath))
801 appid = manifestroot.attrib['package']
804 app.metadatapath = metadatapath
805 if appid is not None:
811 def sorted_builds(builds):
812 return sorted(builds, key=lambda build: int(build.versionCode))
815 esc_newlines = re.compile(r'\\( |\n)')
818 def post_metadata_parse(app):
819 # TODO keep native types, convert only for .txt metadata
820 for k, v in app.items():
821 if type(v) in (float, int):
825 app['builds'] = app.pop('Builds')
827 if 'flavours' in app and app['flavours'] == [True]:
828 app['flavours'] = 'yes'
830 if isinstance(app.Categories, str):
831 app.Categories = [app.Categories]
832 elif app.Categories is None:
833 app.Categories = ['None']
835 app.Categories = [str(i) for i in app.Categories]
837 def _yaml_bool_unmapable(v):
838 return v in (True, False, [True], [False])
840 def _yaml_bool_unmap(v):
850 _bool_allowed = ('disable', 'kivy', 'maven', 'buildozer')
854 for build in app['builds']:
855 if not isinstance(build, Build):
857 for k, v in build.items():
859 if flagtype(k) == TYPE_LIST:
860 if _yaml_bool_unmapable(v):
861 build[k] = _yaml_bool_unmap(v)
863 if isinstance(v, str):
865 elif isinstance(v, bool):
870 elif flagtype(k) is TYPE_INT:
872 elif flagtype(k) is TYPE_STRING:
873 if isinstance(v, bool) and k in _bool_allowed:
876 if _yaml_bool_unmapable(v):
877 build[k] = _yaml_bool_unmap(v)
882 app.builds = sorted_builds(builds)
885 # Parse metadata for a single application.
887 # 'metadatapath' - the filename to read. The package id for the application comes
888 # from this filename. Pass None to get a blank entry.
890 # Returns a dictionary containing all the details of the application. There are
891 # two major kinds of information in the dictionary. Keys beginning with capital
892 # letters correspond directory to identically named keys in the metadata file.
893 # Keys beginning with lower case letters are generated in one way or another,
894 # and are not found verbatim in the metadata.
896 # Known keys not originating from the metadata are:
898 # 'builds' - a list of dictionaries containing build information
899 # for each defined build
900 # 'comments' - a list of comments from the metadata file. Each is
901 # a list of the form [field, comment] where field is
902 # the name of the field it preceded in the metadata
903 # file. Where field is None, the comment goes at the
904 # end of the file. Alternatively, 'build:version' is
905 # for a comment before a particular build version.
906 # 'descriptionlines' - original lines of description as formatted in the
911 bool_true = re.compile(r'([Yy]es|[Tt]rue)')
912 bool_false = re.compile(r'([Nn]o|[Ff]alse)')
916 if bool_true.match(s):
918 if bool_false.match(s):
920 warn_or_exception(_("Invalid boolean '%s'") % s)
923 def parse_metadata(metadatapath, check_vcs=False):
924 '''parse metadata file, optionally checking the git repo for metadata first'''
926 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
927 accepted = fdroidserver.common.config['accepted_formats']
928 if ext not in accepted:
929 warn_or_exception(_('"{path}" is not an accepted format, convert to: {formats}')
930 .format(path=metadatapath, formats=', '.join(accepted)))
933 app.metadatapath = metadatapath
934 name, _ignored = fdroidserver.common.get_extension(os.path.basename(metadatapath))
935 if name == '.fdroid':
940 with open(metadatapath, 'r', encoding='utf-8') as mf:
942 parse_txt_metadata(mf, app)
944 parse_json_metadata(mf, app)
946 parse_yaml_metadata(mf, app)
948 warn_or_exception(_('Unknown metadata format: {path}')
949 .format(path=metadatapath))
951 if check_vcs and app.Repo:
952 build_dir = fdroidserver.common.get_build_dir(app)
953 metadata_in_repo = os.path.join(build_dir, '.fdroid.yml')
954 if not os.path.isfile(metadata_in_repo):
955 vcs, build_dir = fdroidserver.common.setup_vcs(app)
956 if isinstance(vcs, fdroidserver.common.vcs_git):
957 vcs.gotorevision('HEAD') # HEAD since we can't know where else to go
958 if os.path.isfile(metadata_in_repo):
959 logging.debug('Including metadata from ' + metadata_in_repo)
960 # do not include fields already provided by main metadata file
961 app_in_repo = parse_metadata(metadata_in_repo)
962 for k, v in app_in_repo.items():
966 post_metadata_parse(app)
970 build = app.builds[-1]
972 root_dir = build.subdir
975 paths = fdroidserver.common.manifest_paths(root_dir, build.gradle)
976 _ignored, _ignored, app.id = fdroidserver.common.parse_androidmanifests(paths, app)
981 def parse_json_metadata(mf, app):
983 # fdroid metadata is only strings and booleans, no floats or ints.
984 # TODO create schema using https://pypi.python.org/pypi/jsonschema
985 jsoninfo = json.load(mf, parse_int=lambda s: s,
986 parse_float=lambda s: s)
988 for f in ['Description', 'Maintainer Notes']:
991 app[f] = '\n'.join(v)
995 def parse_yaml_metadata(mf, app):
996 yamldata = yaml.load(mf, Loader=YamlLoader)
1002 def write_yaml(mf, app):
1004 # import rumael.yaml and check version
1007 except ImportError as e:
1008 raise FDroidException('ruamel.yaml not instlled, can not write metadata.') from e
1009 if not ruamel.yaml.__version__:
1010 raise FDroidException('ruamel.yaml.__version__ not accessible. Please make sure a ruamel.yaml >= 0.13 is installed..')
1011 m = re.match('(?P<major>[0-9]+)\.(?P<minor>[0-9]+)\.(?P<patch>[0-9]+)(-.+)?',
1012 ruamel.yaml.__version__)
1014 raise FDroidException('ruamel.yaml version malfored, please install an upstream version of ruamel.yaml')
1015 if int(m.group('major')) < 0 or int(m.group('minor')) < 13:
1016 raise FDroidException('currently installed version of ruamel.yaml ({}) is too old, >= 1.13 required.'.format(ruamel.yaml.__version__))
1017 # suiteable version ruamel.yaml imported successfully
1019 _yaml_bools_true = ('y', 'Y', 'yes', 'Yes', 'YES',
1020 'true', 'True', 'TRUE',
1022 _yaml_bools_false = ('n', 'N', 'no', 'No', 'NO',
1023 'false', 'False', 'FALSE',
1024 'off', 'Off', 'OFF')
1025 _yaml_bools_plus_lists = []
1026 _yaml_bools_plus_lists.extend(_yaml_bools_true)
1027 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_true])
1028 _yaml_bools_plus_lists.extend(_yaml_bools_false)
1029 _yaml_bools_plus_lists.extend([[x] for x in _yaml_bools_false])
1031 def _class_as_dict_representer(dumper, data):
1032 '''Creates a YAML representation of a App/Build instance'''
1033 return dumper.represent_dict(data)
1035 def _field_to_yaml(typ, value):
1036 if typ is TYPE_STRING:
1037 if value in _yaml_bools_plus_lists:
1038 return ruamel.yaml.scalarstring.SingleQuotedScalarString(str(value))
1040 elif typ is TYPE_INT:
1042 elif typ is TYPE_MULTILINE:
1044 return ruamel.yaml.scalarstring.preserve_literal(str(value))
1047 elif typ is TYPE_SCRIPT:
1049 return ruamel.yaml.scalarstring.preserve_literal(value)
1055 def _app_to_yaml(app):
1056 cm = ruamel.yaml.comments.CommentedMap()
1057 insert_newline = False
1058 for field in yaml_app_field_order:
1060 # next iteration will need to insert a newline
1061 insert_newline = True
1063 if app.get(field) or field is 'Builds':
1064 # .txt calls it 'builds' internally, everywhere else its 'Builds'
1065 if field is 'Builds':
1066 if app.get('builds'):
1067 cm.update({field: _builds_to_yaml(app)})
1068 elif field is 'CurrentVersionCode':
1069 cm.update({field: _field_to_yaml(TYPE_INT, getattr(app, field))})
1071 cm.update({field: _field_to_yaml(fieldtype(field), getattr(app, field))})
1074 # we need to prepend a newline in front of this field
1075 insert_newline = False
1076 # inserting empty lines is not supported so we add a
1077 # bogus comment and over-write its value
1078 cm.yaml_set_comment_before_after_key(field, 'bogus')
1079 cm.ca.items[field][1][-1].value = '\n'
1082 def _builds_to_yaml(app):
1083 fields = ['versionName', 'versionCode']
1084 fields.extend(build_flags_order)
1085 builds = ruamel.yaml.comments.CommentedSeq()
1086 for build in app.builds:
1087 b = ruamel.yaml.comments.CommentedMap()
1088 for field in fields:
1089 if hasattr(build, field) and getattr(build, field):
1090 value = getattr(build, field)
1091 if field == 'gradle' and value == ['off']:
1092 value = [ruamel.yaml.scalarstring.SingleQuotedScalarString('off')]
1093 if field in ('disable', 'kivy', 'maven', 'buildozer'):
1096 elif value == 'yes':
1098 b.update({field: _field_to_yaml(flagtype(field), value)})
1101 # insert extra empty lines between build entries
1102 for i in range(1, len(builds)):
1103 builds.yaml_set_comment_before_after_key(i, 'bogus')
1104 builds.ca.items[i][1][-1].value = '\n'
1108 yaml_app_field_order = [
1144 'UpdateCheckIgnore',
1149 'CurrentVersionCode',
1154 yaml_app = _app_to_yaml(app)
1155 ruamel.yaml.round_trip_dump(yaml_app, mf, indent=4, block_seq_indent=2)
1158 build_line_sep = re.compile(r'(?<!\\),')
1159 build_cont = re.compile(r'^[ \t]')
1162 def parse_txt_metadata(mf, app):
1166 def add_buildflag(p, build):
1168 warn_or_exception(_("Empty build flag at {linedesc}")
1169 .format(linedesc=linedesc))
1170 bv = p.split('=', 1)
1172 warn_or_exception(_("Invalid build flag at {line} in {linedesc}")
1173 .format(line=buildlines[0], linedesc=linedesc))
1178 pk = 'androidupdate' # avoid conflicting with Build(dict).update()
1181 pv = split_list_values(pv)
1183 elif t == TYPE_STRING or t == TYPE_SCRIPT:
1185 elif t == TYPE_BOOL:
1186 build[pk] = _decode_bool(pv)
1188 def parse_buildline(lines):
1190 parts = [p.replace("\\,", ",") for p in re.split(build_line_sep, v)]
1192 warn_or_exception(_("Invalid build format: {value} in {name}")
1193 .format(value=v, name=mf.name))
1195 build.versionName = parts[0]
1196 build.versionCode = parts[1]
1197 check_versionCode(build.versionCode)
1199 if parts[2].startswith('!'):
1200 # For backwards compatibility, handle old-style disabling,
1201 # including attempting to extract the commit from the message
1202 build.disable = parts[2][1:]
1203 commit = 'unknown - see disabled'
1204 index = parts[2].rfind('at ')
1206 commit = parts[2][index + 3:]
1207 if commit.endswith(')'):
1208 commit = commit[:-1]
1209 build.commit = commit
1211 build.commit = parts[2]
1213 add_buildflag(p, build)
1217 def check_versionCode(versionCode):
1221 warn_or_exception(_('Invalid versionCode: "{versionCode}" is not an integer!')
1222 .format(versionCode=versionCode))
1224 def add_comments(key):
1227 app.comments[key] = list(curcomments)
1232 multiline_lines = []
1242 linedesc = "%s:%d" % (mf.name, c)
1243 line = line.rstrip('\r\n')
1245 if build_cont.match(line):
1246 if line.endswith('\\'):
1247 buildlines.append(line[:-1].lstrip())
1249 buildlines.append(line.lstrip())
1250 bl = ''.join(buildlines)
1251 add_buildflag(bl, build)
1254 if not build.commit and not build.disable:
1255 warn_or_exception(_("No commit specified for {versionName} in {linedesc}")
1256 .format(versionName=build.versionName, linedesc=linedesc))
1258 app.builds.append(build)
1259 add_comments('build:' + build.versionCode)
1265 if line.startswith("#"):
1266 curcomments.append(line[1:].strip())
1269 f, v = line.split(':', 1)
1271 warn_or_exception(_("Invalid metadata in: ") + linedesc)
1273 if f not in app_fields:
1274 warn_or_exception(_('Unrecognised app field: ') + f)
1276 # Translate obsolete fields...
1277 if f == 'Market Version':
1278 f = 'Current Version'
1279 if f == 'Market Version Code':
1280 f = 'Current Version Code'
1282 f = f.replace(' ', '')
1284 ftype = fieldtype(f)
1285 if ftype not in [TYPE_BUILD, TYPE_BUILD_V2]:
1287 if ftype == TYPE_MULTILINE:
1290 warn_or_exception(_("Unexpected text on same line as {field} in {linedesc}")
1291 .format(field=f, linedesc=linedesc))
1292 elif ftype == TYPE_STRING:
1294 elif ftype == TYPE_LIST:
1295 app[f] = split_list_values(v)
1296 elif ftype == TYPE_BUILD:
1297 if v.endswith("\\"):
1300 buildlines.append(v[:-1])
1302 build = parse_buildline([v])
1303 app.builds.append(build)
1304 add_comments('build:' + app.builds[-1].versionCode)
1305 elif ftype == TYPE_BUILD_V2:
1308 warn_or_exception(_('Build should have comma-separated '
1309 'versionName and versionCode, '
1310 'not "{value}", in {linedesc}')
1311 .format(value=v, linedesc=linedesc))
1313 build.versionName = vv[0]
1314 build.versionCode = vv[1]
1315 check_versionCode(build.versionCode)
1317 if build.versionCode in vc_seen:
1318 warn_or_exception(_('Duplicate build recipe found for versionCode {versionCode} in {linedesc}')
1319 .format(versionCode=build.versionCode, linedesc=linedesc))
1320 vc_seen.add(build.versionCode)
1323 elif ftype == TYPE_OBSOLETE:
1324 pass # Just throw it away!
1326 warn_or_exception(_("Unrecognised field '{field}' in {linedesc}")
1327 .format(field=f, linedesc=linedesc))
1328 elif mode == 1: # Multiline field
1331 app[f] = '\n'.join(multiline_lines)
1332 del multiline_lines[:]
1334 multiline_lines.append(line)
1335 elif mode == 2: # Line continuation mode in Build Version
1336 if line.endswith("\\"):
1337 buildlines.append(line[:-1])
1339 buildlines.append(line)
1340 build = parse_buildline(buildlines)
1341 app.builds.append(build)
1342 add_comments('build:' + app.builds[-1].versionCode)
1346 # Mode at end of file should always be 0
1348 warn_or_exception(_("{field} not terminated in {name}")
1349 .format(field=f, name=mf.name))
1351 warn_or_exception(_("Unterminated continuation in {name}")
1352 .format(name=mf.name))
1354 warn_or_exception(_("Unterminated build in {name}")
1355 .format(name=mf.name))
1360 def write_plaintext_metadata(mf, app, w_comment, w_field, w_build):
1362 def field_to_attr(f):
1364 Translates human-readable field names to attribute names, e.g.
1365 'Auto Name' to 'AutoName'
1367 return f.replace(' ', '')
1369 def attr_to_field(k):
1371 Translates attribute names to human-readable field names, e.g.
1372 'AutoName' to 'Auto Name'
1376 f = re.sub(r'([a-z])([A-Z])', r'\1 \2', k)
1379 def w_comments(key):
1380 if key not in app.comments:
1382 for line in app.comments[key]:
1385 def w_field_always(f, v=None):
1386 key = field_to_attr(f)
1392 def w_field_nonempty(f, v=None):
1393 key = field_to_attr(f)
1400 w_field_nonempty('Disabled')
1401 w_field_nonempty('AntiFeatures')
1402 w_field_nonempty('Provides')
1403 w_field_always('Categories')
1404 w_field_always('License')
1405 w_field_nonempty('Author Name')
1406 w_field_nonempty('Author Email')
1407 w_field_nonempty('Author Web Site')
1408 w_field_always('Web Site')
1409 w_field_always('Source Code')
1410 w_field_always('Issue Tracker')
1411 w_field_nonempty('Changelog')
1412 w_field_nonempty('Donate')
1413 w_field_nonempty('FlattrID')
1414 w_field_nonempty('Bitcoin')
1415 w_field_nonempty('Litecoin')
1417 w_field_nonempty('Name')
1418 w_field_nonempty('Auto Name')
1419 w_field_nonempty('Summary')
1420 w_field_nonempty('Description', description_txt(app.Description))
1422 if app.RequiresRoot:
1423 w_field_always('Requires Root', 'yes')
1426 w_field_always('Repo Type')
1427 w_field_always('Repo')
1429 w_field_always('Binaries')
1432 for build in app.builds:
1434 if build.versionName == "Ignore":
1437 w_comments('build:%s' % build.versionCode)
1441 if app.MaintainerNotes:
1442 w_field_always('Maintainer Notes', app.MaintainerNotes)
1445 w_field_nonempty('Archive Policy')
1446 w_field_always('Auto Update Mode')
1447 w_field_always('Update Check Mode')
1448 w_field_nonempty('Update Check Ignore')
1449 w_field_nonempty('Vercode Operation')
1450 w_field_nonempty('Update Check Name')
1451 w_field_nonempty('Update Check Data')
1452 if app.CurrentVersion:
1453 w_field_always('Current Version')
1454 w_field_always('Current Version Code')
1455 if app.NoSourceSince:
1457 w_field_always('No Source Since')
1461 # Write a metadata file in txt format.
1463 # 'mf' - Writer interface (file, StringIO, ...)
1464 # 'app' - The app data
1465 def write_txt(mf, app):
1467 def w_comment(line):
1468 mf.write("# %s\n" % line)
1474 elif t == TYPE_MULTILINE:
1475 v = '\n' + v + '\n.'
1476 mf.write("%s:%s\n" % (f, v))
1479 mf.write("Build:%s,%s\n" % (build.versionName, build.versionCode))
1481 for f in build_flags_order:
1487 if f == 'androidupdate':
1488 f = 'update' # avoid conflicting with Build(dict).update()
1489 mf.write(' %s=' % f)
1490 if t == TYPE_STRING:
1492 elif t == TYPE_BOOL:
1494 elif t == TYPE_SCRIPT:
1496 for s in v.split(' && '):
1500 mf.write(' && \\\n ')
1502 elif t == TYPE_LIST:
1503 mf.write(','.join(v))
1507 write_plaintext_metadata(mf, app, w_comment, w_field, w_build)
1510 def write_metadata(metadatapath, app):
1511 _ignored, ext = fdroidserver.common.get_extension(metadatapath)
1512 accepted = fdroidserver.common.config['accepted_formats']
1513 if ext not in accepted:
1514 warn_or_exception(_('Cannot write "{path}", not an accepted format, use: {formats}')
1515 .format(path=metadatapath, formats=', '.join(accepted)))
1518 with open(metadatapath, 'w', encoding='utf8') as mf:
1520 return write_txt(mf, app)
1522 return write_yaml(mf, app)
1523 except FDroidException as e:
1524 os.remove(metadatapath)
1527 warn_or_exception(_('Unknown metadata format: %s') % metadatapath)
1530 def add_metadata_arguments(parser):
1531 '''add common command line flags related to metadata processing'''
1532 parser.add_argument("-W", default='error',
1533 help=_("force errors to be warnings, or ignore"))