chiark / gitweb /
lint: accept new category Sports & Health
[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 optparse import OptionParser
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     "Office",
123     "Phone & SMS",
124     "Reading",
125     "Science & Education",
126     "Security",
127     "Sports & Health",
128     "System",
129     "Theming",
130     "Time",
131     "Writing",
132 ])
133
134 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
135
136
137 def main():
138
139     global config, options, curid, count
140     curid = None
141
142     count = Counter()
143
144     def warn(message):
145         global curid, count
146         if curid:
147             print "%s:" % curid
148             curid = None
149             count['app'] += 1
150         print '    %s' % message
151         count['warn'] += 1
152
153     # Parse command line...
154     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
155     parser.add_option("-v", "--verbose", action="store_true", default=False,
156                       help="Spew out even more information than normal")
157     parser.add_option("-q", "--quiet", action="store_true", default=False,
158                       help="Restrict output to warnings and errors")
159     (options, args) = 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(args, 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         # Check for lists using the wrong characters
265         validchars = ['*', '#']
266         lchar = ''
267         lcount = 0
268         for l in app['Description']:
269             if len(l) < 1:
270                 continue
271
272             for um in desc_url.finditer(l):
273                 url = um.group(1)
274                 for m, r in http_warnings:
275                     if m.match(url):
276                         warn("URL '%s' in Description: %s" % (url, r))
277
278             c = l.decode('utf-8')[0]
279             if c == lchar:
280                 lcount += 1
281                 if lcount > 3 and lchar not in validchars:
282                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
283                     break
284             else:
285                 lchar = c
286                 lcount = 1
287
288         # Regex checks in all kinds of fields
289         for f in regex_warnings:
290             for m, r in regex_warnings[f]:
291                 v = app[f]
292                 if type(v) == str:
293                     if v is None:
294                         continue
295                     if m.match(v):
296                         warn("%s '%s': %s" % (f, v, r))
297                 elif type(v) == list:
298                     for l in v:
299                         if m.match(l):
300                             warn("%s at line '%s': %s" % (f, l, r))
301
302         # Build warnings
303         for build in app['builds']:
304             if build['disable']:
305                 continue
306             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
307                 if build['commit'] and build['commit'].startswith(s):
308                     warn("Branch '%s' used as commit in build '%s'" % (
309                         s, build['version']))
310                 for srclib in build['srclibs']:
311                     ref = srclib.split('@')[1].split('/')[0]
312                     if ref.startswith(s):
313                         warn("Branch '%s' used as commit in srclib '%s'" % (
314                             s, srclib))
315
316         if not curid:
317             print
318
319     if count['warn'] > 0:
320         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
321             count['warn'], count['app'], count['app_total']))
322         sys.exit(1)
323
324
325 if __name__ == "__main__":
326     main()