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