chiark / gitweb /
lint: remove Children as valid category
[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     "Development",
115     "Games",
116     "Internet",
117     "Multimedia",
118     "Navigation",
119     "Office",
120     "Phone & SMS",
121     "Reading",
122     "Science & Education",
123     "Security",
124     "System",
125     "Wallpaper",
126 ])
127
128 desc_url = re.compile("[^[]\[([^ ]+)( |\]|$)")
129
130
131 def main():
132
133     global config, options, curid, count
134     curid = None
135
136     count = Counter()
137
138     def warn(message):
139         global curid, count
140         if curid:
141             print "%s:" % curid
142             curid = None
143             count['app'] += 1
144         print '    %s' % message
145         count['warn'] += 1
146
147     # Parse command line...
148     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
149     parser.add_option("-v", "--verbose", action="store_true", default=False,
150                       help="Spew out even more information than normal")
151     parser.add_option("-q", "--quiet", action="store_true", default=False,
152                       help="Restrict output to warnings and errors")
153     (options, args) = parser.parse_args()
154
155     config = common.read_config(options)
156
157     # Get all apps...
158     allapps = metadata.read_metadata(xref=True)
159     apps = common.read_app_args(args, allapps, False)
160
161     filling_ucms = re.compile('^(Tags.*|RepoManifest.*)')
162
163     for appid, app in apps.iteritems():
164         if app['Disabled']:
165             continue
166
167         curid = appid
168         count['app_total'] += 1
169
170         # enabled_builds = 0
171         lowest_vercode = -1
172         curbuild = None
173         for build in app['builds']:
174             if not build['disable']:
175                 # enabled_builds += 1
176                 vercode = int(build['vercode'])
177                 if lowest_vercode == -1 or vercode < lowest_vercode:
178                     lowest_vercode = vercode
179             if not curbuild or int(build['vercode']) > int(curbuild['vercode']):
180                 curbuild = build
181
182         # Incorrect UCM
183         if (curbuild and curbuild['commit']
184                 and app['Update Check Mode'] == 'RepoManifest'
185                 and not curbuild['commit'].startswith('unknown')
186                 and curbuild['vercode'] == app['Current Version Code']
187                 and not curbuild['forcevercode']
188                 and any(s in curbuild['commit'] for s in '.,_-/')):
189             warn("Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
190                 curbuild['commit'], app['Update Check Mode']))
191
192         # Summary size limit
193         summ_chars = len(app['Summary'])
194         if summ_chars > config['char_limits']['Summary']:
195             warn("Summary of length %s is over the %i char limit" % (
196                 summ_chars, config['char_limits']['Summary']))
197
198         # Redundant info
199         if app['Web Site'] and app['Source Code']:
200             if app['Web Site'].lower() == app['Source Code'].lower():
201                 warn("Website '%s' is just the app's source code link" % app['Web Site'])
202
203         if filling_ucms.match(app['Update Check Mode']):
204             if all(app[f] == metadata.app_defaults[f] for f in [
205                     'Auto Name',
206                     'Current Version',
207                     'Current Version Code',
208                     ]):
209                 warn("UCM is set but it looks like checkupdates hasn't been run yet")
210
211         if app['Update Check Name'] == appid:
212             warn("Update Check Name is set to the known app id - it can be removed")
213
214         cvc = int(app['Current Version Code'])
215         if cvc > 0 and cvc < lowest_vercode:
216             warn("Current Version Code is lower than any enabled build")
217
218         # Missing or incorrect categories
219         if not app['Categories']:
220             warn("Categories are not set")
221         for categ in app['Categories']:
222             if categ not in categories:
223                 warn("Category '%s' is not valid" % categ)
224
225         if app['Name'] and app['Name'] == app['Auto Name']:
226             warn("Name '%s' is just the auto name" % app['Name'])
227
228         name = app['Name'] or app['Auto Name']
229         if app['Summary'] and name:
230             if app['Summary'].lower() == name.lower():
231                 warn("Summary '%s' is just the app's name" % app['Summary'])
232
233         desc = app['Description']
234         if app['Summary'] and desc and len(desc) == 1:
235             if app['Summary'].lower() == desc[0].lower():
236                 warn("Description '%s' is just the app's summary" % app['Summary'])
237
238         # Description size limit
239         desc_charcount = sum(len(l) for l in desc)
240         if desc_charcount > config['char_limits']['Description']:
241             warn("Description of length %s is over the %i char limit" % (
242                 desc_charcount, config['char_limits']['Description']))
243
244         maxcols = 140
245         for l in app['Description']:
246             if any(l.startswith(c) for c in ['*', '#']):
247                 continue
248             if any(len(w) > maxcols for w in l.split(' ')):
249                 continue
250             if len(l) > maxcols:
251                 warn("Description should be wrapped to 80-120 chars")
252                 break
253
254         if (not desc[0] or not desc[-1]
255                 or any(not desc[l - 1] and not desc[l] for l in range(1, len(desc)))):
256             warn("Description has an extra empty line")
257
258         # Check for lists using the wrong characters
259         validchars = ['*', '#']
260         lchar = ''
261         lcount = 0
262         for l in app['Description']:
263             if len(l) < 1:
264                 continue
265
266             for um in desc_url.finditer(l):
267                 url = um.group(1)
268                 for m, r in http_warnings:
269                     if m.match(url):
270                         warn("URL '%s' in Description: %s" % (url, r))
271
272             c = l.decode('utf-8')[0]
273             if c == lchar:
274                 lcount += 1
275                 if lcount > 3 and lchar not in validchars:
276                     warn("Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar)
277                     break
278             else:
279                 lchar = c
280                 lcount = 1
281
282         # Regex checks in all kinds of fields
283         for f in regex_warnings:
284             for m, r in regex_warnings[f]:
285                 v = app[f]
286                 if type(v) == str:
287                     if v is None:
288                         continue
289                     if m.match(v):
290                         warn("%s '%s': %s" % (f, v, r))
291                 elif type(v) == list:
292                     for l in v:
293                         if m.match(l):
294                             warn("%s at line '%s': %s" % (f, l, r))
295
296         # Build warnings
297         for build in app['builds']:
298             if build['disable']:
299                 continue
300             for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
301                 if build['commit'] and build['commit'].startswith(s):
302                     warn("Branch '%s' used as commit in build '%s'" % (
303                         s, build['version']))
304                 for srclib in build['srclibs']:
305                     ref = srclib.split('@')[1].split('/')[0]
306                     if ref.startswith(s):
307                         warn("Branch '%s' used as commit in srclib '%s'" % (
308                             s, srclib))
309
310         if not curid:
311             print
312
313     if count['warn'] > 0:
314         logging.warn("Found a total of %i warnings in %i apps out of %i total." % (
315             count['warn'], count['app'], count['app_total']))
316         sys.exit(1)
317
318
319 if __name__ == "__main__":
320     main()