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