chiark / gitweb /
replace deprecated optparse with argparse
[fdroidserver.git] / fdroidserver / lint.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # lint.py - part of the FDroid server tool
5 # Copyright (C) 2013-2014 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 th
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public Licen
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 from argparse import ArgumentParser
21 import re
22 import logging
23 import common
24 import metadata
25 import sys
26 from collections import Counter
27 from sets import Set
28
29 config = None
30 options = None
31
32
33 def enforce_https(domain):
34     return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
35             domain + " URLs should always use https://")
36
37 https_enforcings = [
38     enforce_https('github.com'),
39     enforce_https('gitlab.com'),
40     enforce_https('bitbucket.org'),
41     enforce_https('gitorious.org'),
42     enforce_https('apache.org'),
43     enforce_https('google.com'),
44     enforce_https('svn.code.sf.net'),
45     enforce_https('googlecode.com'),
46 ]
47
48
49 def forbid_shortener(domain):
50     return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
51             "URL shorteners should not be used")
52
53 http_url_shorteners = [
54     forbid_shortener('goo.gl'),
55     forbid_shortener('t.co'),
56     forbid_shortener('ur1.ca'),
57 ]
58
59 http_warnings = https_enforcings + http_url_shorteners + [
60     (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
61      "Appending .git is not necessary"),
62     (re.compile(r'(.*/blob/master/|.*raw\.github.com/[^/]*/[^/]*/master/)'),
63      "Use /HEAD/ instead of /master/ to point at a file in the default branch"),
64 ]
65
66 regex_warnings = {
67     'Web Site': http_warnings + [
68     ],
69     'Source Code': http_warnings + [
70     ],
71     'Repo': https_enforcings + [
72     ],
73     'Issue Tracker': http_warnings + [
74         (re.compile(r'.*github\.com/[^/]+/[^/]+[/]*$'),
75          "/issues is missing"),
76     ],
77     'Donate': http_warnings + [
78         (re.compile(r'.*flattr\.com'),
79          "Flattr donation methods belong in the FlattrID flag"),
80     ],
81     'Changelog': http_warnings + [
82     ],
83     'License': [
84         (re.compile(r'^(|None|Unknown)$'),
85          "No license specified"),
86     ],
87     'Summary': [
88         (re.compile(r'^$'),
89          "Summary yet to be filled"),
90         (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
91          "No need to specify that the app is Free Software"),
92         (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
93          "No need to specify that the app is for Android"),
94         (re.compile(r'.*[a-z0-9][.!?]( |$)'),
95          "Punctuation should be avoided"),
96     ],
97     'Description': [
98         (re.compile(r'^No description available$'),
99          "Description yet to be filled"),
100         (re.compile(r'\s*[*#][^ .]'),
101          "Invalid bulleted list"),
102         (re.compile(r'^\s'),
103          "Unnecessary leading space"),
104         (re.compile(r'.*\s$'),
105          "Unnecessary trailing space"),
106         (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
107          "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
108         (re.compile(r'.*[^[]https?://[^ ]+'),
109          "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
110     ],
111 }
112
113 categories = Set([
114     "Connectivity",
115     "Development",
116     "Games",
117     "Graphics",
118     "Internet",
119     "Money",
120     "Multimedia",
121     "Navigation",
122     "Phone & SMS",
123     "Reading",
124     "Science & Education",
125     "Security",
126     "Sports & Health",
127     "System",
128     "Theming",
129     "Time",
130     "Writing",
131 ])
132
133 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
134
135
136 def main():
137
138     global config, options, curid, count
139     curid = None
140
141     count = Counter()
142
143     def warn(message):
144         global curid, count
145         if curid:
146             print "%s:" % curid
147             curid = None
148             count['app'] += 1
149         print '    %s' % message
150         count['warn'] += 1
151
152     # Parse command line...
153     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
154     parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
155     parser.add_argument("-v", "--verbose", action="store_true", default=False,
156                         help="Spew out even more information than normal")
157     parser.add_argument("-q", "--quiet", action="store_true", default=False,
158                         help="Restrict output to warnings and errors")
159     options = parser.parse_args()
160
161     config = common.read_config(options)
162
163     # Get all apps...
164     allapps = metadata.read_metadata(xref=True)
165     apps = common.read_app_args(options.appid, allapps, False)
166
167     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
168
169     for appid, app in apps.iteritems():
170         if app['Disabled']:
171             continue
172
173         curid = appid
174         count['app_total'] += 1
175
176         # enabled_builds = 0
177         lowest_vercode = -1
178         curbuild = None
179         for build in app['builds']:
180             if not build['disable']:
181                 # enabled_builds += 1
182                 vercode = int(build['vercode'])
183                 if lowest_vercode == -1 or vercode < lowest_vercode:
184                     lowest_vercode = vercode
185             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
186                 curbuild = build
187
188         # Incorrect UCM
189         if (curbuild and curbuild['commit']
190                 and app['Update Check Mode'] == 'RepoManifest'
191                 and not curbuild['commit'].startswith('unknown')
192                 and curbuild['vercode'] == app['Current Version Code']
193                 and not curbuild['forcevercode']
194                 and any(s in curbuild['commit'] for s in '.,_-/')):
195             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
196                 curbuild['commit'], app['Update Check Mode']))
197
198         # Summary size limit
199         summ_chars = len(app['Summary'])
200         if summ_chars > config['char_limits']['Summary']:
201             warn("Summary of length %s is over the %i char limit" % (
202                 summ_chars, config['char_limits']['Summary']))
203
204         # Redundant info
205         if app['Web Site'] and app['Source Code']:
206             if app['Web Site'].lower() == app['Source Code'].lower():
207                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
208
209         if filling_ucms.match(app['Update Check Mode']):
210             if all(app[f] == metadata.app_defaults[f] for f in [
211                     'Auto Name',
212                     'Current Version',
213                     'Current Version Code',
214                     ]):
215                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
216
217         if app['Update Check Name'] == appid:
218             warn("Update Check Name is set to the known app id - it can be removed")
219
220         cvc = int(app['Current Version Code'])
221         if cvc > 0 and cvc < lowest_vercode:
222             warn("Current Version Code is lower than any enabled build")
223
224         # Missing or incorrect categories
225         if not app['Categories']:
226             warn("Categories are not set")
227         for categ in app['Categories']:
228             if categ not in categories:
229                 warn("Category '%s' is not valid" % categ)
230
231         if app['Name'] and app['Name'] == app['Auto Name']:
232             warn("Name '%s' is just the auto name" % app['Name'])
233
234         name = app['Name'] or app['Auto Name']
235         if app['Summary'] and name:
236             if app['Summary'].lower() == name.lower():
237                 warn("Summary '%s' is just the app's name" % app['Summary'])
238
239         desc = app['Description']
240         if app['Summary'] and desc and len(desc) == 1:
241             if app['Summary'].lower() == desc[0].lower():
242                 warn("Description '%s' is just the app's summary" % app['Summary'])
243
244         # Description size limit
245         desc_charcount = sum(len(l) for l in desc)
246         if desc_charcount > config['char_limits']['Description']:
247             warn("Description of length %s is over the %i char limit" % (
248                 desc_charcount, config['char_limits']['Description']))
249
250         maxcols = 140
251         for l in app['Description']:
252             if any(l.startswith(c) for c in ['*', '#']):
253                 continue
254             if any(len(w) > maxcols for w in l.split(' ')):
255                 continue
256             if len(l) > maxcols:
257                 warn("Description should be wrapped to 80-120 chars")
258                 break
259
260         if (not desc[0] or not desc[-1]
261                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
262             warn("Description has an extra empty line")
263
264         seenlines = set()
265         for l in app['Description']:
266             if len(l) < 1:
267                 continue
268             if l in seenlines:
269                 warn("Description has a duplicate line")
270             seenlines.add(l)
271
272         for l in app['Description']:
273             for um in desc_url.finditer(l):
274                 url = um.group(1)
275                 for m, r in http_warnings:
276                     if m.match(url):
277                         warn("URL '%s' in Description: %s" % (url, r))
278
279         # Check for lists using the wrong characters
280         validchars = ['*', '#']
281         lchar = ''
282         lcount = 0
283         for l in app['Description']:
284             if len(l) < 1:
285                 lcount = 0
286                 continue
287
288             if l[0] == lchar and l[1] == ' ':
289                 lcount += 1
290                 if lcount > 2 and lchar not in validchars:
291                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
292                     break
293             else:
294                 lchar = l[0]
295                 lcount = 1
296
297         # Regex checks in all kinds of fields
298         for f in regex_warnings:
299             for m, r in regex_warnings[f]:
300                 v = app[f]
301                 if type(v) == str:
302                     if v is None:
303                         continue
304                     if m.match(v):
305                         warn("%s '%s': %s" % (f, v, r))
306                 elif type(v) == list:
307                     for l in v:
308                         if m.match(l):
309                             warn("%s at line '%s': %s" % (f, l, r))
310
311         # Build warnings
312         for build in app['builds']:
313             if build['disable']:
314                 continue
315             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
316                 if build['commit'] and build['commit'].startswith(s):
317                     warn("Branch '%s' used as commit in build '%s'" % (
318                         s, build['version']))
319                 for srclib in build['srclibs']:
320                     ref = srclib.split('@')[1].split('/')[0]
321                     if ref.startswith(s):
322                         warn("Branch '%s' used as commit in srclib '%s'" % (
323                             s, srclib))
324
325         if not curid:
326             print
327
328     if count['warn'] > 0:
329         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
330             count['warn'], count['app'], count['app_total']))
331         sys.exit(1)
332
333
334 if __name__ == "__main__":
335     main()