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