chiark / gitweb /
lint can no longer properly detect unset Summary/Description
[fdroidserver.git] / fdroidserver / lint.py
1 #!/usr/bin/env python3
2 #
3 # lint.py - part of the FDroid server tool
4 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
5 #
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10 #
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See th
14 # GNU Affero General Public License for more details.
15 #
16 # You should have received a copy of the GNU Affero General Public Licen
17 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
18
19 from argparse import ArgumentParser
20 import os
21 import re
22 import sys
23
24 from . import common
25 from . import metadata
26 from . import rewritemeta
27
28 config = None
29 options = None
30
31
32 def enforce_https(domain):
33     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
34             domain + " URLs should always use https://")
35
36
37 https_enforcings = [
38     enforce_https('github.com'),
39     enforce_https('gitlab.com'),
40     enforce_https('bitbucket.org'),
41     enforce_https('apache.org'),
42     enforce_https('google.com'),
43     enforce_https('svn.code.sf.net'),
44 ]
45
46
47 def forbid_shortener(domain):
48     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
49             "URL shorteners should not be used")
50
51
52 http_url_shorteners = [
53     forbid_shortener('goo.gl'),
54     forbid_shortener('t.co'),
55     forbid_shortener('ur1.ca'),
56     forbid_shortener('is.gd'),
57     forbid_shortener('bit.ly'),
58     forbid_shortener('tiny.cc'),
59     forbid_shortener('tinyurl.com'),
60 ]
61
62 http_checks = https_enforcings + http_url_shorteners + [
63     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
64      "Appending .git is not necessary"),
65     (re.compile(r'.*://[^/]*(github|gitlab|bitbucket|rawgit)[^/]*/([^/]+/){1,3}master'),
66      "Use /HEAD instead of /master to point at a file in the default branch"),
67 ]
68
69 regex_checks = {
70     'WebSite': http_checks,
71     'SourceCode': http_checks,
72     'Repo': https_enforcings,
73     'IssueTracker': http_checks + [
74         (re.compile(r'.*github\.com/[^/]+/[^/]+/*$'),
75          "/issues is missing"),
76         (re.compile(r'.*gitlab\.com/[^/]+/[^/]+/*$'),
77          "/issues is missing"),
78     ],
79     'Donate': http_checks + [
80         (re.compile(r'.*flattr\.com'),
81          "Flattr donation methods belong in the FlattrID flag"),
82     ],
83     'Changelog': http_checks,
84     'Author Name': [
85         (re.compile(r'^\s'),
86          "Unnecessary leading space"),
87         (re.compile(r'.*\s$'),
88          "Unnecessary trailing space"),
89     ],
90     'Summary': [
91         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
92          "No need to specify that the app is Free Software"),
93         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
94          "No need to specify that the app is for Android"),
95         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
96          "Punctuation should be avoided"),
97         (re.compile(r'^\s'),
98          "Unnecessary leading space"),
99         (re.compile(r'.*\s$'),
100          "Unnecessary trailing space"),
101     ],
102     'Description': [
103         (re.compile(r'\s*[*#][^ .]'),
104          "Invalid bulleted list"),
105         (re.compile(r'^\s'),
106          "Unnecessary leading space"),
107         (re.compile(r'.*\s$'),
108          "Unnecessary trailing space"),
109         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
110          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
111         (re.compile(r'(^|.* )https?://[^ ]+'),
112          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
113     ],
114 }
115
116 locale_pattern = re.compile(r'^[a-z]{2,3}(-[A-Z][A-Z])?$')
117
118
119 def check_regexes(app):
120     for f, checks in regex_checks.items():
121         for m, r in checks:
122             v = app.get(f)
123             t = metadata.fieldtype(f)
124             if t == metadata.TYPE_MULTILINE:
125                 for l in v.splitlines():
126                     if m.match(l):
127                         yield "%s at line '%s': %s" % (f, l, r)
128             else:
129                 if v is None:
130                     continue
131                 if m.match(v):
132                     yield "%s '%s': %s" % (f, v, r)
133
134
135 def get_lastbuild(builds):
136     lowest_vercode = -1
137     lastbuild = None
138     for build in builds:
139         if not build.disable:
140             vercode = int(build.versionCode)
141             if lowest_vercode == -1 or vercode < lowest_vercode:
142                 lowest_vercode = vercode
143         if not lastbuild or int(build.versionCode) > int(lastbuild.versionCode):
144             lastbuild = build
145     return lastbuild
146
147
148 def check_ucm_tags(app):
149     lastbuild = get_lastbuild(app.builds)
150     if (lastbuild is not None
151             and lastbuild.commit
152             and app.UpdateCheckMode == 'RepoManifest'
153             and not lastbuild.commit.startswith('unknown')
154             and lastbuild.versionCode == app.CurrentVersionCode
155             and not lastbuild.forcevercode
156             and any(s in lastbuild.commit for s in '.,_-/')):
157         yield "Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
158             lastbuild.commit, app.UpdateCheckMode)
159
160
161 def check_char_limits(app):
162     limits = config['char_limits']
163
164     if len(app.Summary) > limits['summary']:
165         yield "Summary of length %s is over the %i char limit" % (
166             len(app.Summary), limits['summary'])
167
168     if len(app.Description) > limits['description']:
169         yield "Description of length %s is over the %i char limit" % (
170             len(app.Description), limits['description'])
171
172
173 def check_old_links(app):
174     usual_sites = [
175         'github.com',
176         'gitlab.com',
177         'bitbucket.org',
178     ]
179     old_sites = [
180         'gitorious.org',
181         'code.google.com',
182     ]
183     if any(s in app.Repo for s in usual_sites):
184         for f in ['WebSite', 'SourceCode', 'IssueTracker', 'Changelog']:
185             v = app.get(f)
186             if any(s in v for s in old_sites):
187                 yield "App is in '%s' but has a link to '%s'" % (app.Repo, v)
188
189
190 def check_useless_fields(app):
191     if app.UpdateCheckName == app.id:
192         yield "Update Check Name is set to the known app id - it can be removed"
193
194
195 filling_ucms = re.compile(r'^(Tags.*|RepoManifest.*)')
196
197
198 def check_checkupdates_ran(app):
199     if filling_ucms.match(app.UpdateCheckMode):
200         if not app.AutoName and not app.CurrentVersion and app.CurrentVersionCode == '0':
201             yield "UCM is set but it looks like checkupdates hasn't been run yet"
202
203
204 def check_empty_fields(app):
205     if not app.Categories:
206         yield "Categories are not set"
207
208
209 all_categories = set([
210     "Connectivity",
211     "Development",
212     "Games",
213     "Graphics",
214     "Internet",
215     "Money",
216     "Multimedia",
217     "Navigation",
218     "Phone & SMS",
219     "Reading",
220     "Science & Education",
221     "Security",
222     "Sports & Health",
223     "System",
224     "Theming",
225     "Time",
226     "Writing",
227 ])
228
229
230 def check_categories(app):
231     for categ in app.Categories:
232         if categ not in all_categories:
233             yield "Category '%s' is not valid" % categ
234
235
236 def check_duplicates(app):
237     if app.Name and app.Name == app.AutoName:
238         yield "Name '%s' is just the auto name - remove it" % app.Name
239
240     links_seen = set()
241     for f in ['Source Code', 'Web Site', 'Issue Tracker', 'Changelog']:
242         v = app.get(f)
243         if not v:
244             continue
245         v = v.lower()
246         if v in links_seen:
247             yield "Duplicate link in '%s': %s" % (f, v)
248         else:
249             links_seen.add(v)
250
251     name = app.Name or app.AutoName
252     if app.Summary and name:
253         if app.Summary.lower() == name.lower():
254             yield "Summary '%s' is just the app's name" % app.Summary
255
256     if app.Summary and app.Description and len(app.Description) == 1:
257         if app.Summary.lower() == app.Description[0].lower():
258             yield "Description '%s' is just the app's summary" % app.Summary
259
260     seenlines = set()
261     for l in app.Description.splitlines():
262         if len(l) < 1:
263             continue
264         if l in seenlines:
265             yield "Description has a duplicate line"
266         seenlines.add(l)
267
268
269 desc_url = re.compile(r'(^|[^[])\[([^ ]+)( |\]|$)')
270
271
272 def check_mediawiki_links(app):
273     wholedesc = ' '.join(app.Description)
274     for um in desc_url.finditer(wholedesc):
275         url = um.group(1)
276         for m, r in http_checks:
277             if m.match(url):
278                 yield "URL '%s' in Description: %s" % (url, r)
279
280
281 def check_bulleted_lists(app):
282     validchars = ['*', '#']
283     lchar = ''
284     lcount = 0
285     for l in app.Description.splitlines():
286         if len(l) < 1:
287             lcount = 0
288             continue
289
290         if l[0] == lchar and l[1] == ' ':
291             lcount += 1
292             if lcount > 2 and lchar not in validchars:
293                 yield "Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar
294                 break
295         else:
296             lchar = l[0]
297             lcount = 1
298
299
300 def check_builds(app):
301     for build in app.builds:
302         if build.disable:
303             if build.disable.startswith('Generated by import.py'):
304                 yield "Build generated by `fdroid import` - remove disable line once ready"
305             continue
306         for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
307             if build.commit and build.commit.startswith(s):
308                 yield "Branch '%s' used as commit in build '%s'" % (s, build.versionName)
309             for srclib in build.srclibs:
310                 ref = srclib.split('@')[1].split('/')[0]
311                 if ref.startswith(s):
312                     yield "Branch '%s' used as commit in srclib '%s'" % (s, srclib)
313
314
315 def check_files_dir(app):
316     dir_path = os.path.join('metadata', app.id)
317     if not os.path.isdir(dir_path):
318         return
319     files = set()
320     for name in os.listdir(dir_path):
321         path = os.path.join(dir_path, name)
322         if not (os.path.isfile(path) or name == 'signatures' or locale_pattern.match(name)):
323             yield "Found non-file at %s" % path
324             continue
325         files.add(name)
326
327     used = {'signatures', }
328     for build in app.builds:
329         for fname in build.patch:
330             if fname not in files:
331                 yield "Unknown file %s in build '%s'" % (fname, build.versionName)
332             else:
333                 used.add(fname)
334
335     for name in files.difference(used):
336         if locale_pattern.match(name):
337             continue
338         yield "Unused file at %s" % os.path.join(dir_path, name)
339
340
341 def check_format(app):
342     if options.format and not rewritemeta.proper_format(app):
343         yield "Run rewritemeta to fix formatting"
344
345
346 def check_license_tag(app):
347     '''Ensure all license tags are in https://spdx.org/license-list'''
348     if app.License.rstrip('+') not in SPDX:
349         yield 'Invalid license tag "%s"! Use only tags from https://spdx.org/license-list' \
350             % (app.License)
351
352
353 def check_extlib_dir(apps):
354     dir_path = os.path.join('build', 'extlib')
355     files = set()
356     for root, dirs, names in os.walk(dir_path):
357         for name in names:
358             files.add(os.path.join(root, name)[len(dir_path) + 1:])
359
360     used = set()
361     for app in apps:
362         for build in app.builds:
363             for path in build.extlibs:
364                 if path not in files:
365                     yield "%s: Unknown extlib %s in build '%s'" % (app.id, path, build.versionName)
366                 else:
367                     used.add(path)
368
369     for path in files.difference(used):
370         if any(path.endswith(s) for s in [
371                 '.gitignore',
372                 'source.txt', 'origin.txt', 'md5.txt',
373                 'LICENSE', 'LICENSE.txt',
374                 'COPYING', 'COPYING.txt',
375                 'NOTICE', 'NOTICE.txt',
376                 ]):
377             continue
378         yield "Unused extlib at %s" % os.path.join(dir_path, path)
379
380
381 def main():
382
383     global config, options
384
385     # Parse command line...
386     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
387     common.setup_global_opts(parser)
388     parser.add_argument("-f", "--format", action="store_true", default=False,
389                         help="Also warn about formatting issues, like rewritemeta -l")
390     parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
391     metadata.add_metadata_arguments(parser)
392     options = parser.parse_args()
393     metadata.warnings_action = options.W
394
395     config = common.read_config(options)
396
397     # Get all apps...
398     allapps = metadata.read_metadata(xref=True)
399     apps = common.read_app_args(options.appid, allapps, False)
400
401     anywarns = False
402
403     apps_check_funcs = []
404     if len(options.appid) == 0:
405         # otherwise it finds tons of unused extlibs
406         apps_check_funcs.append(check_extlib_dir)
407     for check_func in apps_check_funcs:
408         for warn in check_func(apps.values()):
409             anywarns = True
410             print(warn)
411
412     for appid, app in apps.items():
413         if app.Disabled:
414             continue
415
416         app_check_funcs = [
417             check_regexes,
418             check_ucm_tags,
419             check_char_limits,
420             check_old_links,
421             check_checkupdates_ran,
422             check_useless_fields,
423             check_empty_fields,
424             check_categories,
425             check_duplicates,
426             check_mediawiki_links,
427             check_bulleted_lists,
428             check_builds,
429             check_files_dir,
430             check_format,
431             check_license_tag,
432         ]
433
434         for check_func in app_check_funcs:
435             for warn in check_func(app):
436                 anywarns = True
437                 print("%s: %s" % (appid, warn))
438
439     if anywarns:
440         sys.exit(1)
441
442
443 # A compiled, public domain list of official SPDX license tags from:
444 # https://github.com/sindresorhus/spdx-license-list/blob/v3.0.1/spdx-simple.json
445 # The deprecated license tags have been removed from the list, they are at the
446 # bottom, starting after the last license tags that start with Z.
447 # This is at the bottom, since its a long list of data
448 SPDX = [
449     "PublicDomain",  # an F-Droid addition, until we can enforce a better option
450     "Glide",
451     "Abstyles",
452     "AFL-1.1",
453     "AFL-1.2",
454     "AFL-2.0",
455     "AFL-2.1",
456     "AFL-3.0",
457     "AMPAS",
458     "APL-1.0",
459     "Adobe-Glyph",
460     "APAFML",
461     "Adobe-2006",
462     "AGPL-1.0",
463     "Afmparse",
464     "Aladdin",
465     "ADSL",
466     "AMDPLPA",
467     "ANTLR-PD",
468     "Apache-1.0",
469     "Apache-1.1",
470     "Apache-2.0",
471     "AML",
472     "APSL-1.0",
473     "APSL-1.1",
474     "APSL-1.2",
475     "APSL-2.0",
476     "Artistic-1.0",
477     "Artistic-1.0-Perl",
478     "Artistic-1.0-cl8",
479     "Artistic-2.0",
480     "AAL",
481     "Bahyph",
482     "Barr",
483     "Beerware",
484     "BitTorrent-1.0",
485     "BitTorrent-1.1",
486     "BSL-1.0",
487     "Borceux",
488     "BSD-2-Clause",
489     "BSD-2-Clause-FreeBSD",
490     "BSD-2-Clause-NetBSD",
491     "BSD-3-Clause",
492     "BSD-3-Clause-Clear",
493     "BSD-3-Clause-No-Nuclear-License",
494     "BSD-3-Clause-No-Nuclear-License-2014",
495     "BSD-3-Clause-No-Nuclear-Warranty",
496     "BSD-4-Clause",
497     "BSD-Protection",
498     "BSD-Source-Code",
499     "BSD-3-Clause-Attribution",
500     "0BSD",
501     "BSD-4-Clause-UC",
502     "bzip2-1.0.5",
503     "bzip2-1.0.6",
504     "Caldera",
505     "CECILL-1.0",
506     "CECILL-1.1",
507     "CECILL-2.0",
508     "CECILL-2.1",
509     "CECILL-B",
510     "CECILL-C",
511     "ClArtistic",
512     "MIT-CMU",
513     "CNRI-Jython",
514     "CNRI-Python",
515     "CNRI-Python-GPL-Compatible",
516     "CPOL-1.02",
517     "CDDL-1.0",
518     "CDDL-1.1",
519     "CPAL-1.0",
520     "CPL-1.0",
521     "CATOSL-1.1",
522     "Condor-1.1",
523     "CC-BY-1.0",
524     "CC-BY-2.0",
525     "CC-BY-2.5",
526     "CC-BY-3.0",
527     "CC-BY-4.0",
528     "CC-BY-ND-1.0",
529     "CC-BY-ND-2.0",
530     "CC-BY-ND-2.5",
531     "CC-BY-ND-3.0",
532     "CC-BY-ND-4.0",
533     "CC-BY-NC-1.0",
534     "CC-BY-NC-2.0",
535     "CC-BY-NC-2.5",
536     "CC-BY-NC-3.0",
537     "CC-BY-NC-4.0",
538     "CC-BY-NC-ND-1.0",
539     "CC-BY-NC-ND-2.0",
540     "CC-BY-NC-ND-2.5",
541     "CC-BY-NC-ND-3.0",
542     "CC-BY-NC-ND-4.0",
543     "CC-BY-NC-SA-1.0",
544     "CC-BY-NC-SA-2.0",
545     "CC-BY-NC-SA-2.5",
546     "CC-BY-NC-SA-3.0",
547     "CC-BY-NC-SA-4.0",
548     "CC-BY-SA-1.0",
549     "CC-BY-SA-2.0",
550     "CC-BY-SA-2.5",
551     "CC-BY-SA-3.0",
552     "CC-BY-SA-4.0",
553     "CC0-1.0",
554     "Crossword",
555     "CrystalStacker",
556     "CUA-OPL-1.0",
557     "Cube",
558     "curl",
559     "D-FSL-1.0",
560     "diffmark",
561     "WTFPL",
562     "DOC",
563     "Dotseqn",
564     "DSDP",
565     "dvipdfm",
566     "EPL-1.0",
567     "ECL-1.0",
568     "ECL-2.0",
569     "eGenix",
570     "EFL-1.0",
571     "EFL-2.0",
572     "MIT-advertising",
573     "MIT-enna",
574     "Entessa",
575     "ErlPL-1.1",
576     "EUDatagrid",
577     "EUPL-1.0",
578     "EUPL-1.1",
579     "Eurosym",
580     "Fair",
581     "MIT-feh",
582     "Frameworx-1.0",
583     "FreeImage",
584     "FTL",
585     "FSFAP",
586     "FSFUL",
587     "FSFULLR",
588     "Giftware",
589     "GL2PS",
590     "Glulxe",
591     "AGPL-3.0",
592     "GFDL-1.1",
593     "GFDL-1.2",
594     "GFDL-1.3",
595     "GPL-1.0",
596     "GPL-2.0",
597     "GPL-3.0",
598     "LGPL-2.1",
599     "LGPL-3.0",
600     "LGPL-2.0",
601     "gnuplot",
602     "gSOAP-1.3b",
603     "HaskellReport",
604     "HPND",
605     "IBM-pibs",
606     "IPL-1.0",
607     "ICU",
608     "ImageMagick",
609     "iMatix",
610     "Imlib2",
611     "IJG",
612     "Info-ZIP",
613     "Intel-ACPI",
614     "Intel",
615     "Interbase-1.0",
616     "IPA",
617     "ISC",
618     "JasPer-2.0",
619     "JSON",
620     "LPPL-1.0",
621     "LPPL-1.1",
622     "LPPL-1.2",
623     "LPPL-1.3a",
624     "LPPL-1.3c",
625     "Latex2e",
626     "BSD-3-Clause-LBNL",
627     "Leptonica",
628     "LGPLLR",
629     "Libpng",
630     "libtiff",
631     "LAL-1.2",
632     "LAL-1.3",
633     "LiLiQ-P-1.1",
634     "LiLiQ-Rplus-1.1",
635     "LiLiQ-R-1.1",
636     "LPL-1.02",
637     "LPL-1.0",
638     "MakeIndex",
639     "MTLL",
640     "MS-PL",
641     "MS-RL",
642     "MirOS",
643     "MITNFA",
644     "MIT",
645     "Motosoto",
646     "MPL-1.0",
647     "MPL-1.1",
648     "MPL-2.0",
649     "MPL-2.0-no-copyleft-exception",
650     "mpich2",
651     "Multics",
652     "Mup",
653     "NASA-1.3",
654     "Naumen",
655     "NBPL-1.0",
656     "Net-SNMP",
657     "NetCDF",
658     "NGPL",
659     "NOSL",
660     "NPL-1.0",
661     "NPL-1.1",
662     "Newsletr",
663     "NLPL",
664     "Nokia",
665     "NPOSL-3.0",
666     "NLOD-1.0",
667     "Noweb",
668     "NRL",
669     "NTP",
670     "Nunit",
671     "OCLC-2.0",
672     "ODbL-1.0",
673     "PDDL-1.0",
674     "OCCT-PL",
675     "OGTSL",
676     "OLDAP-2.2.2",
677     "OLDAP-1.1",
678     "OLDAP-1.2",
679     "OLDAP-1.3",
680     "OLDAP-1.4",
681     "OLDAP-2.0",
682     "OLDAP-2.0.1",
683     "OLDAP-2.1",
684     "OLDAP-2.2",
685     "OLDAP-2.2.1",
686     "OLDAP-2.3",
687     "OLDAP-2.4",
688     "OLDAP-2.5",
689     "OLDAP-2.6",
690     "OLDAP-2.7",
691     "OLDAP-2.8",
692     "OML",
693     "OPL-1.0",
694     "OSL-1.0",
695     "OSL-1.1",
696     "OSL-2.0",
697     "OSL-2.1",
698     "OSL-3.0",
699     "OpenSSL",
700     "OSET-PL-2.1",
701     "PHP-3.0",
702     "PHP-3.01",
703     "Plexus",
704     "PostgreSQL",
705     "psfrag",
706     "psutils",
707     "Python-2.0",
708     "QPL-1.0",
709     "Qhull",
710     "Rdisc",
711     "RPSL-1.0",
712     "RPL-1.1",
713     "RPL-1.5",
714     "RHeCos-1.1",
715     "RSCPL",
716     "RSA-MD",
717     "Ruby",
718     "SAX-PD",
719     "Saxpath",
720     "SCEA",
721     "SWL",
722     "SMPPL",
723     "Sendmail",
724     "SGI-B-1.0",
725     "SGI-B-1.1",
726     "SGI-B-2.0",
727     "OFL-1.0",
728     "OFL-1.1",
729     "SimPL-2.0",
730     "Sleepycat",
731     "SNIA",
732     "Spencer-86",
733     "Spencer-94",
734     "Spencer-99",
735     "SMLNJ",
736     "SugarCRM-1.1.3",
737     "SISSL",
738     "SISSL-1.2",
739     "SPL-1.0",
740     "Watcom-1.0",
741     "TCL",
742     "TCP-wrappers",
743     "Unlicense",
744     "TMate",
745     "TORQUE-1.1",
746     "TOSL",
747     "Unicode-DFS-2015",
748     "Unicode-DFS-2016",
749     "Unicode-TOU",
750     "UPL-1.0",
751     "NCSA",
752     "Vim",
753     "VOSTROM",
754     "VSL-1.0",
755     "W3C-20150513",
756     "W3C-19980720",
757     "W3C",
758     "Wsuipa",
759     "Xnet",
760     "X11",
761     "Xerox",
762     "XFree86-1.1",
763     "xinetd",
764     "xpp",
765     "XSkat",
766     "YPL-1.0",
767     "YPL-1.1",
768     "Zed",
769     "Zend-2.0",
770     "Zimbra-1.3",
771     "Zimbra-1.4",
772     "Zlib",
773     "zlib-acknowledgement",
774     "ZPL-1.1",
775     "ZPL-2.0",
776     "ZPL-2.1",
777 ]
778
779 if __name__ == "__main__":
780     main()