chiark / gitweb /
Added AUM and UCM matching patterns
[fdroidserver.git] / fdroidserver / metadata.py
1 # -*- coding: utf-8 -*-
2 #
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>
6 #
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.
11 #
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.
16 #
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/>.
19
20 import os, re, glob
21 import cgi
22
23 class MetaDataException(Exception):
24     def __init__(self, value):
25         self.value = value
26
27     def __str__(self):
28         return repr(self.value)
29
30 # Designates a metadata field type and checks that it matches 
31 #
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
37 #
38 class FieldType():
39     def __init__(self, name, matching, sep, fields, attrs):
40         self.name = name
41         if type(matching) is str:
42             self.matching = re.compile(matching)
43         elif type(matching) is list:
44             self.matching = matching
45         self.sep = sep
46         self.fields = fields
47         self.attrs = attrs
48
49     def _assert_regex(self, values, appid):
50         for v in values:
51             if not self.matching.match(v):
52                 raise MetaDataException("'%s' is not a valid %s in %s"
53                         % (v, self.name, appid))
54
55     def _assert_list(self, values, appid):
56         for v in values:
57             if v not in self.matching:
58                 raise MetaDataException("'%s' is not a valid %s in %s"
59                         % (v, self.name, appid))
60
61     def check(self, value, appid):
62         if type(value) is not str or not value:
63             return
64         if self.sep is not None:
65             values = value.split(self.sep)
66         else:
67             values = [value]
68         if type(self.matching) is list:
69             self._assert_list(values, appid)
70         else:
71             self._assert_regex(values, appid)
72
73
74 # Generic value types
75 valuetypes = {
76     'int' : FieldType("Integer",
77         r'^[0-9]+$', None,
78         [ 'FlattrID' ],
79         [ 'vercode' ]),
80
81     'http' : FieldType("HTTP link",
82         r'^http[s]?://', None,
83         [ "Web Site", "Source Code", "Issue Tracker", "Donate" ], []),
84
85     'bitcoin' : FieldType("Bitcoin address",
86         r'^[a-zA-Z0-9]{27,34}$', None,
87         [ "Bitcoin" ],
88         [ ]),
89
90     'litecoin' : FieldType("Litecoin address",
91         r'^[a-zA-Z0-9]{27,34}$', None,
92         [ "Bitcoin" ],
93         [ ]),
94
95     'archive' : FieldType("Archive Policy",
96         r'^[0-9]+ versions$', None,
97         [ "Archive Policy" ],
98         [ ]),
99
100     'bool' : FieldType("Boolean",
101         ['yes', 'no'], None,
102         [ ],
103         [ 'submodules', 'oldsdkloc', 'forceversion', 'forcevercode',
104             'fixtrans', 'fixapos', 'novcheck' ]),
105
106     'Bool' : FieldType("Boolean",
107         ['Yes', 'No'], None,
108         [ "Requires Root" ],
109         [ ]),
110
111     'antifeatures' : FieldType("Anti-Feature",
112         [ "Ads", "Tracking", "NonFreeNet", "NonFreeDep", "NonFreeAdd" ], ',',
113         [ "AntiFeatures" ],
114         [ ]),
115
116     'autoupdatemodes' : FieldType("Auto Update Mode",
117         r"^(Version .+|None)$", None,
118         [ "Auto Update Mode" ],
119         [ ]),
120
121     'updatecheckmodes' : FieldType("Update Check Mode",
122         r"^(Tags|RepoManifest|RepoManifest/.+|RepoTrunk|HTTP|Static|None)$", None,
123         [ "Update Check Mode" ],
124         [ ])
125 }
126
127 # Check an app's metadata information for integrity errors
128 def check_metadata(info):
129     for k, t in valuetypes.iteritems():
130         for field in t.fields:
131             if field in info:
132                 t.check(info[field], info['id'])
133                 if k == 'Bool':
134                     info[field] = info[field] == "Yes"
135         for build in info['builds']:
136             for attr in t.attrs:
137                 if attr in build:
138                     t.check(build[attr], info['id'])
139                     if k == 'bool':
140                         build[attr] = build[attr] == "yes"
141                 elif k == 'bool':
142                     build[attr] = False
143
144 # Formatter for descriptions. Create an instance, and call parseline() with
145 # each line of the description source from the metadata. At the end, call
146 # end() and then text_plain, text_wiki and text_html will contain the result.
147 class DescriptionFormatter:
148     stNONE = 0
149     stPARA = 1
150     stUL = 2
151     stOL = 3
152     bold = False
153     ital = False
154     state = stNONE
155     text_plain = ''
156     text_wiki = ''
157     text_html = ''
158     linkResolver = None
159     def __init__(self, linkres):
160         self.linkResolver = linkres
161     def endcur(self, notstates=None):
162         if notstates and self.state in notstates:
163             return
164         if self.state == self.stPARA:
165             self.endpara()
166         elif self.state == self.stUL:
167             self.endul()
168         elif self.state == self.stOL:
169             self.endol()
170     def endpara(self):
171         self.text_plain += '\n'
172         self.text_html += '</p>'
173         self.state = self.stNONE
174     def endul(self):
175         self.text_html += '</ul>'
176         self.state = self.stNONE
177     def endol(self):
178         self.text_html += '</ol>'
179         self.state = self.stNONE
180
181     def formatted(self, txt, html):
182         formatted = ''
183         if html:
184             txt = cgi.escape(txt)
185         while True:
186             index = txt.find("''")
187             if index == -1:
188                 return formatted + txt
189             formatted += txt[:index]
190             txt = txt[index:]
191             if txt.startswith("'''"):
192                 if html:
193                     if self.bold:
194                         formatted += '</b>'
195                     else:
196                         formatted += '<b>'
197                 self.bold = not self.bold
198                 txt = txt[3:]
199             else:
200                 if html:
201                     if self.ital:
202                         formatted += '</i>'
203                     else:
204                         formatted += '<i>'
205                 self.ital = not self.ital
206                 txt = txt[2:]
207
208
209     def linkify(self, txt):
210         linkified_plain = ''
211         linkified_html = ''
212         while True:
213             index = txt.find("[")
214             if index == -1:
215                 return (linkified_plain + self.formatted(txt, False), linkified_html + self.formatted(txt, True))
216             linkified_plain += self.formatted(txt[:index], False)
217             linkified_html += self.formatted(txt[:index], True)
218             txt = txt[index:]
219             if txt.startswith("[["):
220                 index = txt.find("]]")
221                 if index == -1:
222                     raise MetaDataException("Unterminated ]]")
223                 url = txt[2:index]
224                 if self.linkResolver:
225                     url, urltext = self.linkResolver(url)
226                 else:
227                     urltext = url
228                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltext) + '</a>'
229                 linkified_plain += urltext
230                 txt = txt[index+2:]
231             else:
232                 index = txt.find("]")
233                 if index == -1:
234                     raise MetaDataException("Unterminated ]")
235                 url = txt[1:index]
236                 index2 = url.find(' ')
237                 if index2 == -1:
238                     urltxt = url
239                 else:
240                     urltxt = url[index2 + 1:]
241                     url = url[:index2]
242                 linkified_html += '<a href="' + url + '">' + cgi.escape(urltxt) + '</a>'
243                 linkified_plain += urltxt
244                 if urltxt != url:
245                     linkified_plain += ' (' + url + ')'
246                 txt = txt[index+1:]
247
248     def addtext(self, txt):
249         p, h = self.linkify(txt)
250         self.text_plain += p
251         self.text_html += h
252
253     def parseline(self, line):
254         self.text_wiki += "%s\n" % line
255         if not line:
256             self.endcur()
257         elif line.startswith('*'):
258             self.endcur([self.stUL])
259             if self.state != self.stUL:
260                 self.text_html += '<ul>'
261                 self.state = self.stUL
262             self.text_html += '<li>'
263             self.text_plain += '*'
264             self.addtext(line[1:])
265             self.text_html += '</li>'
266         elif line.startswith('#'):
267             self.endcur([self.stOL])
268             if self.state != self.stOL:
269                 self.text_html += '<ol>'
270                 self.state = self.stOL
271             self.text_html += '<li>'
272             self.text_plain += '*' #TODO: lazy - put the numbers in!
273             self.addtext(line[1:])
274             self.text_html += '</li>'
275         else:
276             self.endcur([self.stPARA])
277             if self.state == self.stNONE:
278                 self.text_html += '<p>'
279                 self.state = self.stPARA
280             elif self.state == self.stPARA:
281                 self.text_html += ' '
282                 self.text_plain += ' '
283             self.addtext(line)
284
285     def end(self):
286         self.endcur()
287
288 # Parse multiple lines of description as written in a metadata file, returning
289 # a single string in plain text format.
290 def description_plain(lines, linkres):
291     ps = DescriptionFormatter(linkres)
292     for line in lines:
293         ps.parseline(line)
294     ps.end()
295     return ps.text_plain
296
297 # Parse multiple lines of description as written in a metadata file, returning
298 # a single string in wiki format. Used for the Maintainer Notes field as well,
299 # because it's the same format.
300 def description_wiki(lines):
301     ps = DescriptionFormatter(None)
302     for line in lines:
303         ps.parseline(line)
304     ps.end()
305     return ps.text_wiki
306
307 # Parse multiple lines of description as written in a metadata file, returning
308 # a single string in HTML format.
309 def description_html(lines,linkres):
310     ps = DescriptionFormatter(linkres)
311     for line in lines:
312         ps.parseline(line)
313     ps.end()
314     return ps.text_html
315
316 def parse_srclib(metafile, **kw):
317
318     thisinfo = {}
319     if metafile and not isinstance(metafile, file):
320         metafile = open(metafile, "r")
321
322     # Defaults for fields that come from metadata
323     thisinfo['Repo Type'] = ''
324     thisinfo['Repo'] = ''
325     thisinfo['Subdir'] = None
326     thisinfo['Prepare'] = None
327     thisinfo['Srclibs'] = None
328     thisinfo['Update Project'] = None
329
330     if metafile is None:
331         return thisinfo
332
333     for line in metafile:
334         line = line.rstrip('\r\n')
335         if not line or line.startswith("#"):
336             continue
337
338         index = line.find(':')
339         if index == -1:
340             raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
341         field = line[:index]
342         value = line[index+1:]
343
344         if field == "Subdir":
345             thisinfo[field] = value.split(',')
346         else:
347             thisinfo[field] = value
348
349     return thisinfo
350
351 # Read all metadata. Returns a list of 'app' objects (which are dictionaries as
352 # returned by the parse_metadata function.
353 def read_metadata(xref=True, package=None):
354     apps = []
355     for basedir in ('metadata', 'tmp'):
356         if not os.path.exists(basedir):
357             os.makedirs(basedir)
358     for metafile in sorted(glob.glob(os.path.join('metadata', '*.txt'))):
359         if package is None or metafile == os.path.join('metadata', package + '.txt'):
360             try:
361                 appinfo = parse_metadata(metafile)
362             except Exception, e:
363                 raise MetaDataException("Problem reading metadata file %s: - %s" % (metafile, str(e)))
364             check_metadata(appinfo)
365             apps.append(appinfo)
366
367     if xref:
368         # Parse all descriptions at load time, just to ensure cross-referencing
369         # errors are caught early rather than when they hit the build server.
370         def linkres(link):
371             for app in apps:
372                 if app['id'] == link:
373                     return ("fdroid.app:" + link, "Dummy name - don't know yet")
374             raise MetaDataException("Cannot resolve app id " + link)
375         for app in apps:
376             try:
377                 description_html(app['Description'], linkres)
378             except Exception, e:
379                 raise MetaDataException("Problem with description of " + app['id'] +
380                         " - " + str(e))
381
382     return apps
383
384 # Get the type expected for a given metadata field.
385 def metafieldtype(name):
386     if name in ['Description', 'Maintainer Notes']:
387         return 'multiline'
388     if name == 'Build Version':
389         return 'build'
390     if name == 'Build':
391         return 'buildv2'
392     if name == 'Use Built':
393         return 'obsolete'
394     return 'string'
395
396 # Parse metadata for a single application.
397 #
398 #  'metafile' - the filename to read. The package id for the application comes
399 #               from this filename. Pass None to get a blank entry.
400 #
401 # Returns a dictionary containing all the details of the application. There are
402 # two major kinds of information in the dictionary. Keys beginning with capital
403 # letters correspond directory to identically named keys in the metadata file.
404 # Keys beginning with lower case letters are generated in one way or another,
405 # and are not found verbatim in the metadata.
406 #
407 # Known keys not originating from the metadata are:
408 #
409 #  'id'               - the application's package ID
410 #  'builds'           - a list of dictionaries containing build information
411 #                       for each defined build
412 #  'comments'         - a list of comments from the metadata file. Each is
413 #                       a tuple of the form (field, comment) where field is
414 #                       the name of the field it preceded in the metadata
415 #                       file. Where field is None, the comment goes at the
416 #                       end of the file. Alternatively, 'build:version' is
417 #                       for a comment before a particular build version.
418 #  'descriptionlines' - original lines of description as formatted in the
419 #                       metadata file.
420 #
421 def parse_metadata(metafile):
422
423     def parse_buildline(lines):
424         value = "".join(lines)
425         parts = [p.replace("\\,", ",")
426                  for p in re.split(r"(?<!\\),", value)]
427         if len(parts) < 3:
428             raise MetaDataException("Invalid build format: " + value + " in " + metafile.name)
429         thisbuild = {}
430         thisbuild['origlines'] = lines
431         thisbuild['version'] = parts[0]
432         thisbuild['vercode'] = parts[1]
433         if parts[2].startswith('!'):
434             # For backwards compatibility, handle old-style disabling,
435             # including attempting to extract the commit from the message
436             thisbuild['disable'] = parts[2][1:]
437             commit = 'unknown - see disabled'
438             index = parts[2].rfind('at ')
439             if index != -1:
440                 commit = parts[2][index+3:]
441                 if commit.endswith(')'):
442                     commit = commit[:-1]
443             thisbuild['commit'] = commit
444         else:
445             thisbuild['commit'] = parts[2]
446         for p in parts[3:]:
447             pk, pv = p.split('=', 1)
448             thisbuild[pk.strip()] = pv
449
450         return thisbuild
451
452     def add_comments(key):
453         if not curcomments:
454             return
455         for comment in curcomments:
456             thisinfo['comments'].append((key, comment))
457         del curcomments[:]
458
459
460     thisinfo = {}
461     if metafile:
462         if not isinstance(metafile, file):
463             metafile = open(metafile, "r")
464         thisinfo['id'] = metafile.name[9:-4]
465     else:
466         thisinfo['id'] = None
467
468     # Defaults for fields that come from metadata...
469     thisinfo['Name'] = None
470     thisinfo['Auto Name'] = ''
471     thisinfo['Categories'] = 'None'
472     thisinfo['Description'] = []
473     thisinfo['Summary'] = ''
474     thisinfo['License'] = 'Unknown'
475     thisinfo['Web Site'] = ''
476     thisinfo['Source Code'] = ''
477     thisinfo['Issue Tracker'] = ''
478     thisinfo['Donate'] = None
479     thisinfo['FlattrID'] = None
480     thisinfo['Bitcoin'] = None
481     thisinfo['Litecoin'] = None
482     thisinfo['Disabled'] = None
483     thisinfo['AntiFeatures'] = None
484     thisinfo['Archive Policy'] = None
485     thisinfo['Update Check Mode'] = 'None'
486     thisinfo['Vercode Operation'] = None
487     thisinfo['Auto Update Mode'] = 'None'
488     thisinfo['Current Version'] = ''
489     thisinfo['Current Version Code'] = '0'
490     thisinfo['Repo Type'] = ''
491     thisinfo['Repo'] = ''
492     thisinfo['Requires Root'] = False
493     thisinfo['No Source Since'] = ''
494
495     # General defaults...
496     thisinfo['builds'] = []
497     thisinfo['comments'] = []
498
499     if metafile is None:
500         return thisinfo
501
502     mode = 0
503     buildlines = []
504     curcomments = []
505     curbuild = None
506
507     for line in metafile:
508         line = line.rstrip('\r\n')
509         if mode == 3:
510             if not any(line.startswith(s) for s in (' ', '\t')):
511                 if 'commit' not in curbuild and 'disable' not in curbuild:
512                     raise MetaDataException("No commit specified for {0} in {1}".format(
513                         curbuild['version'], metafile.name))
514                 thisinfo['builds'].append(curbuild)
515                 add_comments('build:' + curbuild['version'])
516                 mode = 0
517             else:
518                 if line.endswith('\\'):
519                     buildlines.append(line[:-1].lstrip())
520                 else:
521                     buildlines.append(line.lstrip())
522                     bl = ''.join(buildlines)
523                     bv = bl.split('=', 1)
524                     if len(bv) != 2:
525                         raise MetaDataException("Invalid build flag at {0} in {1}".
526                                 format(buildlines[0], metafile.name))
527                     name, val = bv
528                     if name in curbuild:
529                         raise MetaDataException("Duplicate definition on {0} in version {1} of {2}".
530                                 format(name, curbuild['version'], metafile.name))
531                     curbuild[name] = val.lstrip()
532                     buildlines = []
533
534         if mode == 0:
535             if not line:
536                 continue
537             if line.startswith("#"):
538                 curcomments.append(line)
539                 continue
540             index = line.find(':')
541             if index == -1:
542                 raise MetaDataException("Invalid metadata in " + metafile.name + " at: " + line)
543             field = line[:index]
544             value = line[index+1:]
545
546             # Translate obsolete fields...
547             if field == 'Market Version':
548                 field = 'Current Version'
549             if field == 'Market Version Code':
550                 field = 'Current Version Code'
551
552             fieldtype = metafieldtype(field)
553             if fieldtype not in ['build', 'buildv2']:
554                 add_comments(field)
555             if fieldtype == 'multiline':
556                 mode = 1
557                 thisinfo[field] = []
558                 if value:
559                     raise MetaDataException("Unexpected text on same line as " + field + " in " + metafile.name)
560             elif fieldtype == 'string':
561                 if field == 'Category' and thisinfo['Categories'] == 'None':
562                     thisinfo['Categories'] = value.replace(';',',')
563                 thisinfo[field] = value
564             elif fieldtype == 'build':
565                 if value.endswith("\\"):
566                     mode = 2
567                     buildlines = [value[:-1]]
568                 else:
569                     thisinfo['builds'].append(parse_buildline([value]))
570                     add_comments('build:' + thisinfo['builds'][-1]['version'])
571             elif fieldtype == 'buildv2':
572                 curbuild = {}
573                 vv = value.split(',')
574                 if len(vv) != 2:
575                     raise MetaDataException('Build should have comma-separated version and vercode, not "{0}", in {1}'.
576                         format(value, metafile.name))
577                 curbuild['version'] = vv[0]
578                 curbuild['vercode'] = vv[1]
579                 buildlines = []
580                 mode = 3
581             elif fieldtype == 'obsolete':
582                 pass        # Just throw it away!
583             else:
584                 raise MetaDataException("Unrecognised field type for " + field + " in " + metafile.name)
585         elif mode == 1:     # Multiline field
586             if line == '.':
587                 mode = 0
588             else:
589                 thisinfo[field].append(line)
590         elif mode == 2:     # Line continuation mode in Build Version
591             if line.endswith("\\"):
592                 buildlines.append(line[:-1])
593             else:
594                 buildlines.append(line)
595                 thisinfo['builds'].append(
596                     parse_buildline(buildlines))
597                 add_comments('build:' + thisinfo['builds'][-1]['version'])
598                 mode = 0
599     add_comments(None)
600
601     # Mode at end of file should always be 0...
602     if mode == 1:
603         raise MetaDataException(field + " not terminated in " + metafile.name)
604     elif mode == 2:
605         raise MetaDataException("Unterminated continuation in " + metafile.name)
606     elif mode == 3:
607         raise MetaDataException("Unterminated build in " + metafile.name)
608
609     if not thisinfo['Description']:
610         thisinfo['Description'].append('No description available')
611
612     return thisinfo
613
614 # Write a metadata file.
615 #
616 # 'dest'    - The path to the output file
617 # 'app'     - The app data
618 def write_metadata(dest, app):
619
620     def writecomments(key):
621         written = 0
622         for pf, comment in app['comments']:
623             if pf == key:
624                 mf.write("%s\n" % comment)
625                 written += 1
626         #if options.verbose and written > 0:
627             #print "...writing comments for " + (key if key else 'EOF')
628
629     def writefield(field, value=None):
630         writecomments(field)
631         if value is None:
632             value = app[field]
633         mf.write("%s:%s\n" % (field, value))
634
635     mf = open(dest, 'w')
636     if app['Disabled']:
637         writefield('Disabled')
638     if app['AntiFeatures']:
639         writefield('AntiFeatures')
640     writefield('Categories')
641     writefield('License')
642     writefield('Web Site')
643     writefield('Source Code')
644     writefield('Issue Tracker')
645     if app['Donate']:
646         writefield('Donate')
647     if app['FlattrID']:
648         writefield('FlattrID')
649     if app['Bitcoin']:
650         writefield('Bitcoin')
651     if app['Litecoin']:
652         writefield('Litecoin')
653     mf.write('\n')
654     if app['Name']:
655         writefield('Name')
656     if app['Auto Name']:
657         writefield('Auto Name')
658     writefield('Summary')
659     writefield('Description', '')
660     for line in app['Description']:
661         mf.write("%s\n" % line)
662     mf.write('.\n')
663     mf.write('\n')
664     if app['Requires Root']:
665         writefield('Requires Root', 'Yes')
666         mf.write('\n')
667     if app['Repo Type']:
668         writefield('Repo Type')
669         writefield('Repo')
670         mf.write('\n')
671     for build in app['builds']:
672         writecomments('build:' + build['version'])
673         mf.write("Build:%s,%s\n" % ( build['version'], build['vercode']))
674
675         # This defines the preferred order for the build items - as in the
676         # manual, they're roughly in order of application.
677         keyorder = ['disable', 'commit', 'subdir', 'submodules', 'init',
678                     'gradle', 'maven', 'oldsdkloc', 'target', 'compilesdk',
679                     'update', 'encoding', 'forceversion', 'forcevercode', 'rm',
680                     'fixtrans', 'fixapos', 'extlibs', 'srclibs', 'patch',
681                     'prebuild', 'scanignore', 'scandelete', 'build', 'buildjni',
682                     'preassemble', 'bindir', 'antcommand', 'novcheck']
683
684         def write_builditem(key, value):
685             if key in ['version', 'vercode', 'origlines']:
686                 return
687             if key in valuetypes['bool'].attrs:
688                 if not value:
689                     return
690                 value = 'yes'
691             #if options.verbose:
692                 #print "...writing {0} : {1}".format(key, value)
693             outline = '    %s=' % key
694             outline += '&& \\\n        '.join([s.lstrip() for s in value.split('&& ')])
695             outline += '\n'
696             mf.write(outline)
697
698         for key in keyorder:
699             if key in build:
700                 write_builditem(key, build[key])
701         for key, value in build.iteritems():
702             if not key in keyorder:
703                 write_builditem(key, value)
704         mf.write('\n')
705
706     if 'Maintainer Notes' in app:
707         writefield('Maintainer Notes', '')
708         for line in app['Maintainer Notes']:
709             mf.write("%s\n" % line)
710         mf.write('.\n')
711         mf.write('\n')
712
713
714     if app['Archive Policy']:
715         writefield('Archive Policy')
716     writefield('Auto Update Mode')
717     writefield('Update Check Mode')
718     if app['Vercode Operation']:
719         writefield('Vercode Operation')
720     if 'Update Check Data' in app:
721         writefield('Update Check Data')
722     if app['Current Version']:
723         writefield('Current Version')
724         writefield('Current Version Code')
725     mf.write('\n')
726     if app['No Source Since']:
727         writefield('No Source Since')
728         mf.write('\n')
729     writecomments(None)
730     mf.close()
731
732