chiark / gitweb /
Merge branch 'replace_optparse_with_argparse' into 'master'
[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         # Old links
210         usual_sites = [
211             'github.com',
212             'gitlab.com',
213             'bitbucket.org',
214         ]
215         old_sites = [
216             'gitorious.org',
217             'code.google.com',
218             # 'sourceforge.net', # too many false positives as of now
219         ]
220         if any(s in app['Repo'] for s in usual_sites):
221             for f in ['Web Site', 'Source Code', 'Issue Tracker', 'Changelog']:
222                 if any(s in app[f] for s in old_sites):
223                     warn("App is in '%s' but has a link to '%s'" % (app['Repo'], app[f]))
224
225         if filling_ucms.match(app['Update Check Mode']):
226             if all(app[f] == metadata.app_defaults[f] for f in [
227                     'Auto Name',
228                     'Current Version',
229                     'Current Version Code',
230                     ]):
231                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
232
233         if app['Update Check Name'] == appid:
234             warn("Update Check Name is set to the known app id - it can be removed")
235
236         cvc = int(app['Current Version Code'])
237         if cvc > 0 and cvc < lowest_vercode:
238             warn("Current Version Code is lower than any enabled build")
239
240         # Missing or incorrect categories
241         if not app['Categories']:
242             warn("Categories are not set")
243         for categ in app['Categories']:
244             if categ not in categories:
245                 warn("Category '%s' is not valid" % categ)
246
247         if app['Name'] and app['Name'] == app['Auto Name']:
248             warn("Name '%s' is just the auto name" % app['Name'])
249
250         name = app['Name'] or app['Auto Name']
251         if app['Summary'] and name:
252             if app['Summary'].lower() == name.lower():
253                 warn("Summary '%s' is just the app's name" % app['Summary'])
254
255         desc = app['Description']
256         if app['Summary'] and desc and len(desc) == 1:
257             if app['Summary'].lower() == desc[0].lower():
258                 warn("Description '%s' is just the app's summary" % app['Summary'])
259
260         # Description size limit
261         desc_charcount = sum(len(l) for l in desc)
262         if desc_charcount > config['char_limits']['Description']:
263             warn("Description of length %s is over the %i char limit" % (
264                 desc_charcount, config['char_limits']['Description']))
265
266         maxcols = 140
267         for l in app['Description']:
268             if any(l.startswith(c) for c in ['*', '#']):
269                 continue
270             if any(len(w) > maxcols for w in l.split(' ')):
271                 continue
272             if len(l) > maxcols:
273                 warn("Description should be wrapped to 80-120 chars")
274                 break
275
276         if (not desc[0] or not desc[-1]
277                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
278             warn("Description has an extra empty line")
279
280         seenlines = set()
281         for l in app['Description']:
282             if len(l) < 1:
283                 continue
284             if l in seenlines:
285                 warn("Description has a duplicate line")
286             seenlines.add(l)
287
288         for l in app['Description']:
289             for um in desc_url.finditer(l):
290                 url = um.group(1)
291                 for m, r in http_warnings:
292                     if m.match(url):
293                         warn("URL '%s' in Description: %s" % (url, r))
294
295         # Check for lists using the wrong characters
296         validchars = ['*', '#']
297         lchar = ''
298         lcount = 0
299         for l in app['Description']:
300             if len(l) < 1:
301                 lcount = 0
302                 continue
303
304             if l[0] == lchar and l[1] == ' ':
305                 lcount += 1
306                 if lcount > 2 and lchar not in validchars:
307                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
308                     break
309             else:
310                 lchar = l[0]
311                 lcount = 1
312
313         # Regex checks in all kinds of fields
314         for f in regex_warnings:
315             for m, r in regex_warnings[f]:
316                 v = app[f]
317                 if type(v) == str:
318                     if v is None:
319                         continue
320                     if m.match(v):
321                         warn("%s '%s': %s" % (f, v, r))
322                 elif type(v) == list:
323                     for l in v:
324                         if m.match(l):
325                             warn("%s at line '%s': %s" % (f, l, r))
326
327         # Build warnings
328         for build in app['builds']:
329             if build['disable']:
330                 continue
331             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
332                 if build['commit'] and build['commit'].startswith(s):
333                     warn("Branch '%s' used as commit in build '%s'" % (
334                         s, build['version']))
335                 for srclib in build['srclibs']:
336                     ref = srclib.split('@')[1].split('/')[0]
337                     if ref.startswith(s):
338                         warn("Branch '%s' used as commit in srclib '%s'" % (
339                             s, srclib))
340
341         if not curid:
342             print
343
344     if count['warn'] > 0:
345         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
346             count['warn'], count['app'], count['app_total']))
347         sys.exit(1)
348
349
350 if __name__ == "__main__":
351     main()