1 # -*- coding: utf-8 -*-
3 # common.py - part of the FDroid server tools
4 # Copyright (C) 2013, Ciaran Gultnieks, ciaran@ciarang.com
5 # Copyright (C) 2013 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/>.
23 class MetaDataException(Exception):
24 def __init__(self, value):
28 return repr(self.value)
30 # Designates a metadata field type and checks that it matches
32 # 'name' - The long name of the field type
33 # 'matching' - List of possible values or regex expression
34 # 'sep' - Separator to use if value may be a list
35 # 'fields' - Metadata fields (Field:Value) of this type
36 # 'attrs' - Build attributes (attr=value) of this type
39 def __init__(self, name, matching, sep, fields, attrs):
41 self.matching = matching
42 if type(matching) is str:
43 self.compiled = re.compile(matching)
48 def _assert_regex(self, values, appid):
50 if not self.compiled.match(v):
51 raise MetaDataException("'%s' is not a valid %s in %s. "
52 % (v, self.name, appid) +
53 "Regex pattern: %s" % (self.matching))
55 def _assert_list(self, values, appid):
57 if v not in self.matching:
58 raise MetaDataException("'%s' is not a valid %s in %s. "
59 % (v, self.name, appid) +
60 "Possible values: %s" % (", ".join(self.matching)))
62 def check(self, value, appid):
63 if type(value) is not str or not value:
65 if self.sep is not None:
66 values = value.split(self.sep)
69 if type(self.matching) is list:
70 self._assert_list(values, appid)
72 self._assert_regex(values, appid)
77 'int' : FieldType("Integer",
82 'http' : FieldType("HTTP link",
83 r'^http[s]?://', None,
84 [ "Web Site", "Source Code", "Issue Tracker", "Donate" ], []),
86 'bitcoin' : FieldType("Bitcoin address",
87 r'^[a-zA-Z0-9]{27,34}$', None,
91 'litecoin' : FieldType("Litecoin address",
92 r'^L[a-zA-Z0-9]{33}$', None,
96 'Bool' : FieldType("Boolean",
101 'bool' : FieldType("Boolean",
104 [ 'submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
105 'fixtrans', 'fixapos', 'novcheck' ]),
107 'Repo Type' : FieldType("Repo Type",
108 [ 'git', 'git-svn', 'svn', 'hg', 'bzr', 'srclib' ], None,
112 'archive' : FieldType("Archive Policy",
113 r'^[0-9]+ versions$', None,
114 [ "Archive Policy" ],
117 'antifeatures' : FieldType("Anti-Feature",
118 [ "Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd", "UpstreamNonFree" ], ',',
122 'autoupdatemodes' : FieldType("Auto Update Mode",
123 r"^(Version .+|None)$", None,
124 [ "Auto Update Mode" ],
127 'updatecheckmodes' : FieldType("Update Check Mode",
128 r"^(Tags|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
129 [ "Update Check Mode" ],
133 # Check an app's metadata information for integrity errors
134 def check_metadata(info):
135 for k, t in valuetypes.iteritems():
136 for field in t.fields:
138 t.check(info[field], info['id'])
140 info[field] = info[field] == "Yes"
141 for build in info['builds']:
144 t.check(build[attr], info['id'])
146 build[attr] = build[attr] == "yes"
150 # Formatter for descriptions. Create an instance, and call parseline() with
151 # each line of the description source from the metadata. At the end, call
152 # end() and then text_plain, text_wiki and text_html will contain the result.
153 class DescriptionFormatter:
165 def __init__(self, linkres):
166 self.linkResolver = linkres
167 def endcur(self, notstates=None):
168 if notstates and self.state in notstates:
170 if self.state == self.stPARA:
172 elif self.state == self.stUL:
174 elif self.state == self.stOL:
177 self.text_plain += '\n'
178 self.text_html += '</p>'
179 self.state = self.stNONE
181 self.text_html += '</ul>'
182 self.state = self.stNONE
184 self.text_html += '</ol>'
185 self.state = self.stNONE
187 def formatted(self, txt, html):
190 txt = cgi.escape(txt)
192 index = txt.find("''")
194 return formatted + txt
195 formatted += txt[:index]
197 if txt.startswith("'''"):
203 self.bold = not self.bold
211 self.ital = not self.ital
215 def linkify(self, txt):
219 index = txt.find("[")
221 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
222 linkified_plain += self.formatted(txt[:index], False)
223 linkified_html += self.formatted(txt[:index], True)
225 if txt.startswith("[["):
226 index = txt.find("]]")
228 raise MetaDataException("Unterminated ]]")
230 if self.linkResolver:
231 url, urltext = self.linkResolver(url)
234 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
235 linkified_plain += urltext
238 index = txt.find("]")
240 raise MetaDataException("Unterminated ]")
242 index2 = url.find(' ')
246 urltxt = url[index2 + 1:]
248 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
249 linkified_plain += urltxt
251 linkified_plain += ' (' + url + ')'
254 def addtext(self, txt):
255 p, h = self.linkify(txt)
259 def parseline(self, line):
260 self.text_wiki += "%s\n" % line
263 elif line.startswith('*'):
264 self.endcur([self.stUL])
265 if self.state != self.stUL:
266 self.text_html += '<ul>'
267 self.state = self.stUL
268 self.text_html += '<li>'
269 self.text_plain += '*'
270 self.addtext(line[1:])
271 self.text_html += '</li>'
272 elif line.startswith('#'):
273 self.endcur([self.stOL])
274 if self.state != self.stOL:
275 self.text_html += '<ol>'
276 self.state = self.stOL
277 self.text_html += '<li>'
278 self.text_plain += '*' #TODO: lazy - put the numbers in!
279 self.addtext(line[1:])
280 self.text_html += '</li>'
282 self.endcur([self.stPARA])
283 if self.state == self.stNONE:
284 self.text_html += '<p>'
285 self.state = self.stPARA
286 elif self.state == self.stPARA:
287 self.text_html += ' '
288 self.text_plain += ' '
294 # Parse multiple lines of description as written in a metadata file, returning
295 # a single string in plain text format.
296 def description_plain(lines, linkres):
297 ps = DescriptionFormatter(linkres)
303 # Parse multiple lines of description as written in a metadata file, returning
304 # a single string in wiki format. Used for the Maintainer Notes field as well,
305 # because it's the same format.
306 def description_wiki(lines):
307 ps = DescriptionFormatter(None)
313 # Parse multiple lines of description as written in a metadata file, returning
314 # a single string in HTML format.
315 def description_html(lines,linkres):
316 ps = DescriptionFormatter(linkres)
322 def parse_srclib(metafile, **kw):
325 if metafile and not isinstance(metafile, file):
326 metafile = open(metafile, "r")
328 # Defaults for fields that come from metadata
329 thisinfo['Repo Type'] = ''
330 thisinfo['Repo'] = ''
331 thisinfo['Subdir'] = None
332 thisinfo['Prepare'] = None
333 thisinfo['Srclibs'] = None
334 thisinfo['Update Project'] = None
339 for line in metafile:
340 line = line.rstrip('\r\n')
341 if not line or line.startswith("#"):
345 field, value = line.split(':',1)
347 raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
349 if field == "Subdir":
350 thisinfo[field] = value.split(',')
352 thisinfo[field] = value
356 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
357 # returned by the parse_metadata function.
358 def read_metadata(xref=True, package=None):
360 for basedir in ('metadata', 'tmp'):
361 if not os.path.exists(basedir):
363 for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
364 if package is None or metafile == os.path.join('metadata', package + '.txt'):
366 appinfo = parse_metadata(metafile)
368 raise MetaDataException("Problem reading metadata file %s: - %s" % (metafile, str(e)))
369 check_metadata(appinfo)
373 # Parse all descriptions at load time, just to ensure cross-referencing
374 # errors are caught early rather than when they hit the build server.
377 if app['id'] == link:
378 return ("fdroid.app:" + link, "Dummy name - don't know yet")
379 raise MetaDataException("Cannot resolve app id " + link)
382 description_html(app['Description'], linkres)
384 raise MetaDataException("Problem with description of " + app['id'] +
389 # Get the type expected for a given metadata field.
390 def metafieldtype(name):
391 if name in ['Description', 'Maintainer Notes']:
393 if name == 'Build Version':
397 if name == 'Use Built':
401 # Parse metadata for a single application.
403 # 'metafile' - the filename to read. The package id for the application comes
404 # from this filename. Pass None to get a blank entry.
406 # Returns a dictionary containing all the details of the application. There are
407 # two major kinds of information in the dictionary. Keys beginning with capital
408 # letters correspond directory to identically named keys in the metadata file.
409 # Keys beginning with lower case letters are generated in one way or another,
410 # and are not found verbatim in the metadata.
412 # Known keys not originating from the metadata are:
414 # 'id' - the application's package ID
415 # 'builds' - a list of dictionaries containing build information
416 # for each defined build
417 # 'comments' - a list of comments from the metadata file. Each is
418 # a tuple of the form (field, comment) where field is
419 # the name of the field it preceded in the metadata
420 # file. Where field is None, the comment goes at the
421 # end of the file. Alternatively, 'build:version' is
422 # for a comment before a particular build version.
423 # 'descriptionlines' - original lines of description as formatted in the
426 def parse_metadata(metafile):
428 def parse_buildline(lines):
429 value = "".join(lines)
430 parts = [p.replace("\\,", ",")
431 for p in re.split(r"(?<!\\),", value)]
433 raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
435 thisbuild['origlines'] = lines
436 thisbuild['version'] = parts[0]
437 thisbuild['vercode'] = parts[1]
438 if parts[2].startswith('!'):
439 # For backwards compatibility, handle old-style disabling,
440 # including attempting to extract the commit from the message
441 thisbuild['disable'] = parts[2][1:]
442 commit = 'unknown - see disabled'
443 index = parts[2].rfind('at ')
445 commit = parts[2][index+3:]
446 if commit.endswith(')'):
448 thisbuild['commit'] = commit
450 thisbuild['commit'] = parts[2]
452 pk, pv = p.split('=', 1)
453 thisbuild[pk.strip()] = pv
457 def add_comments(key):
460 for comment in curcomments:
461 thisinfo['comments'].append((key, comment))
467 if not isinstance(metafile, file):
468 metafile = open(metafile, "r")
469 thisinfo['id'] = metafile.name[9:-4]
471 thisinfo['id'] = None
473 # Defaults for fields that come from metadata...
474 thisinfo['Name'] = None
475 thisinfo['Provides'] = None
476 thisinfo['Auto Name'] = ''
477 thisinfo['Categories'] = 'None'
478 thisinfo['Description'] = []
479 thisinfo['Summary'] = ''
480 thisinfo['License'] = 'Unknown'
481 thisinfo['Web Site'] = ''
482 thisinfo['Source Code'] = ''
483 thisinfo['Issue Tracker'] = ''
484 thisinfo['Donate'] = None
485 thisinfo['FlattrID'] = None
486 thisinfo['Bitcoin'] = None
487 thisinfo['Litecoin'] = None
488 thisinfo['Disabled'] = None
489 thisinfo['AntiFeatures'] = None
490 thisinfo['Archive Policy'] = None
491 thisinfo['Update Check Mode'] = 'None'
492 thisinfo['Vercode Operation'] = None
493 thisinfo['Auto Update Mode'] = 'None'
494 thisinfo['Current Version'] = ''
495 thisinfo['Current Version Code'] = '0'
496 thisinfo['Repo Type'] = ''
497 thisinfo['Repo'] = ''
498 thisinfo['Requires Root'] = False
499 thisinfo['No Source Since'] = ''
501 # General defaults...
502 thisinfo['builds'] = []
503 thisinfo['comments'] = []
513 for line in metafile:
514 line = line.rstrip('\r\n')
516 if not any(line.startswith(s) for s in (' ', '\t')):
517 if 'commit' not in curbuild and 'disable' not in curbuild:
518 raise MetaDataException("No commit specified for {0} in {1}".format(
519 curbuild['version'], metafile.name))
520 thisinfo['builds'].append(curbuild)
521 add_comments('build:' + curbuild['version'])
524 if line.endswith('\\'):
525 buildlines.append(line[:-1].lstrip())
527 buildlines.append(line.lstrip())
528 bl = ''.join(buildlines)
529 bv = bl.split('=', 1)
531 raise MetaDataException("Invalid build flag at {0} in {1}".
532 format(buildlines[0], metafile.name))
535 raise MetaDataException("Duplicate definition on {0} in version {1} of {2}".
536 format(name, curbuild['version'], metafile.name))
537 curbuild[name] = val.lstrip()
543 if line.startswith("#"):
544 curcomments.append(line)
547 field, value = line.split(':',1)
549 raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
551 # Translate obsolete fields...
552 if field == 'Market Version':
553 field = 'Current Version'
554 if field == 'Market Version Code':
555 field = 'Current Version Code'
557 fieldtype = metafieldtype(field)
558 if fieldtype not in ['build', 'buildv2']:
560 if fieldtype == 'multiline':
564 raise MetaDataException("Unexpected text on same line as " + field + " in " + metafile.name)
565 elif fieldtype == 'string':
566 if field == 'Category' and thisinfo['Categories'] == 'None':
567 thisinfo['Categories'] = value.replace(';',',')
568 thisinfo[field] = value
569 elif fieldtype == 'build':
570 if value.endswith("\\"):
572 buildlines = [value[:-1]]
574 thisinfo['builds'].append(parse_buildline([value]))
575 add_comments('build:' + thisinfo['builds'][-1]['version'])
576 elif fieldtype == 'buildv2':
578 vv = value.split(',')
580 raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'.
581 format(value, metafile.name))
582 curbuild['version'] = vv[0]
583 curbuild['vercode'] = vv[1]
586 elif fieldtype == 'obsolete':
587 pass # Just throw it away!
589 raise MetaDataException("Unrecognised field type for " + field + " in " + metafile.name)
590 elif mode == 1: # Multiline field
594 thisinfo[field].append(line)
595 elif mode == 2: # Line continuation mode in Build Version
596 if line.endswith("\\"):
597 buildlines.append(line[:-1])
599 buildlines.append(line)
600 thisinfo['builds'].append(
601 parse_buildline(buildlines))
602 add_comments('build:' + thisinfo['builds'][-1]['version'])
606 # Mode at end of file should always be 0...
608 raise MetaDataException(field + " not terminated in " + metafile.name)
610 raise MetaDataException("Unterminated continuation in " + metafile.name)
612 raise MetaDataException("Unterminated build in " + metafile.name)
614 if not thisinfo['Description']:
615 thisinfo['Description'].append('No description available')
619 # Write a metadata file.
621 # 'dest' - The path to the output file
622 # 'app' - The app data
623 def write_metadata(dest, app):
625 def writecomments(key):
627 for pf, comment in app['comments']:
629 mf.write("%s\n" % comment)
631 #if options.verbose and written > 0:
632 #print "...writing comments for " + (key if key else 'EOF')
634 def writefield(field, value=None):
638 mf.write("%s:%s\n" % (field, value))
642 writefield('Disabled')
643 if app['AntiFeatures']:
644 writefield('AntiFeatures')
646 writefield('Provides')
647 writefield('Categories')
648 writefield('License')
649 writefield('Web Site')
650 writefield('Source Code')
651 writefield('Issue Tracker')
655 writefield('FlattrID')
657 writefield('Bitcoin')
659 writefield('Litecoin')
664 writefield('Auto Name')
665 writefield('Summary')
666 writefield('Description', '')
667 for line in app['Description']:
668 mf.write("%s\n" % line)
671 if app['Requires Root']:
672 writefield('Requires Root', 'Yes')
675 writefield('Repo Type')
678 for build in app['builds']:
679 writecomments('build:' + build['version'])
680 mf.write("Build:%s,%s\n" % ( build['version'], build['vercode']))
682 # This defines the preferred order for the build items - as in the
683 # manual, they're roughly in order of application.
684 keyorder = ['disable', 'commit', 'subdir', 'submodules', 'init',
685 'gradle', 'maven', 'oldsdkloc', 'target', 'compilesdk',
686 'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
687 'fixtrans', 'fixapos', 'extlibs', 'srclibs', 'patch',
688 'prebuild', 'scanignore', 'scandelete', 'build', 'buildjni',
689 'preassemble', 'bindir', 'antcommand', 'novcheck']
691 def write_builditem(key, value):
692 if key in ['version', 'vercode', 'origlines']:
694 if key in valuetypes['bool'].attrs:
699 #print "...writing {0} : {1}".format(key, value)
700 outline = ' %s=' % key
701 outline += '&& \\\n '.join([s.lstrip() for s in value.split('&& ')])
707 write_builditem(key, build[key])
708 for key, value in build.iteritems():
709 if not key in keyorder:
710 write_builditem(key, value)
713 if 'Maintainer Notes' in app:
714 writefield('Maintainer Notes', '')
715 for line in app['Maintainer Notes']:
716 mf.write("%s\n" % line)
721 if app['Archive Policy']:
722 writefield('Archive Policy')
723 writefield('Auto Update Mode')
724 writefield('Update Check Mode')
725 if app['Vercode Operation']:
726 writefield('Vercode Operation')
727 if 'Update Check Data' in app:
728 writefield('Update Check Data')
729 if app['Current Version']:
730 writefield('Current Version')
731 writefield('Current Version Code')
733 if app['No Source Since']:
734 writefield('No Source Since')