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