PageRenderTime 63ms CodeModel.GetById 25ms RepoModel.GetById 0ms app.codeStats 0ms

/fdroidserver/lint.py

https://gitlab.com/dj-tech/fdroidserver
Python | 432 lines | 341 code | 71 blank | 20 comment | 95 complexity | 0ecc4f82bdc17526292e8e4f0c256a6c MD5 | raw file
  1. #!/usr/bin/env python3
  2. #
  3. # lint.py - part of the FDroid server tool
  4. # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
  5. #
  6. # This program is free software: you can redistribute it and/or modify
  7. # it under the terms of the GNU Affero General Public License as published by
  8. # the Free Software Foundation, either version 3 of the License, or
  9. # (at your option) any later version.
  10. #
  11. # This program is distributed in the hope that it will be useful,
  12. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See th
  14. # GNU Affero General Public License for more details.
  15. #
  16. # You should have received a copy of the GNU Affero General Public Licen
  17. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  18. from argparse import ArgumentParser
  19. import os
  20. import re
  21. import sys
  22. from . import common
  23. from . import metadata
  24. from . import rewritemeta
  25. config = None
  26. options = None
  27. def enforce_https(domain):
  28. return (re.compile(r'.*[^sS]://[^/]*' + re.escape(domain) + r'(/.*)?'),
  29. domain + " URLs should always use https://")
  30. https_enforcings = [
  31. enforce_https('github.com'),
  32. enforce_https('gitlab.com'),
  33. enforce_https('bitbucket.org'),
  34. enforce_https('apache.org'),
  35. enforce_https('google.com'),
  36. enforce_https('svn.code.sf.net'),
  37. ]
  38. def forbid_shortener(domain):
  39. return (re.compile(r'https?://[^/]*' + re.escape(domain) + r'/.*'),
  40. "URL shorteners should not be used")
  41. http_url_shorteners = [
  42. forbid_shortener('goo.gl'),
  43. forbid_shortener('t.co'),
  44. forbid_shortener('ur1.ca'),
  45. ]
  46. http_checks = https_enforcings + http_url_shorteners + [
  47. (re.compile(r'.*github\.com/[^/]+/[^/]+\.git'),
  48. "Appending .git is not necessary"),
  49. (re.compile(r'.*://[^/]*(github|gitlab|bitbucket|rawgit)[^/]*/([^/]+/){1,3}master'),
  50. "Use /HEAD instead of /master to point at a file in the default branch"),
  51. ]
  52. regex_checks = {
  53. 'Web Site': http_checks,
  54. 'Source Code': http_checks,
  55. 'Repo': https_enforcings,
  56. 'Issue Tracker': http_checks + [
  57. (re.compile(r'.*github\.com/[^/]+/[^/]+/*$'),
  58. "/issues is missing"),
  59. (re.compile(r'.*gitlab\.com/[^/]+/[^/]+/*$'),
  60. "/issues is missing"),
  61. ],
  62. 'Donate': http_checks + [
  63. (re.compile(r'.*flattr\.com'),
  64. "Flattr donation methods belong in the FlattrID flag"),
  65. ],
  66. 'Changelog': http_checks,
  67. 'Author Name': [
  68. (re.compile(r'^\s'),
  69. "Unnecessary leading space"),
  70. (re.compile(r'.*\s$'),
  71. "Unnecessary trailing space"),
  72. ],
  73. 'License': [
  74. (re.compile(r'^(|None|Unknown)$'),
  75. "No license specified"),
  76. ],
  77. 'Summary': [
  78. (re.compile(r'^$'),
  79. "Summary yet to be filled"),
  80. (re.compile(r'.*\b(free software|open source)\b.*', re.IGNORECASE),
  81. "No need to specify that the app is Free Software"),
  82. (re.compile(r'.*((your|for).*android|android.*(app|device|client|port|version))', re.IGNORECASE),
  83. "No need to specify that the app is for Android"),
  84. (re.compile(r'.*[a-z0-9][.!?]( |$)'),
  85. "Punctuation should be avoided"),
  86. (re.compile(r'^\s'),
  87. "Unnecessary leading space"),
  88. (re.compile(r'.*\s$'),
  89. "Unnecessary trailing space"),
  90. ],
  91. 'Description': [
  92. (re.compile(r'^No description available$'),
  93. "Description yet to be filled"),
  94. (re.compile(r'\s*[*#][^ .]'),
  95. "Invalid bulleted list"),
  96. (re.compile(r'^\s'),
  97. "Unnecessary leading space"),
  98. (re.compile(r'.*\s$'),
  99. "Unnecessary trailing space"),
  100. (re.compile(r'.*([^[]|^)\[[^:[\]]+( |\]|$)'),
  101. "Invalid link - use [http://foo.bar Link title] or [http://foo.bar]"),
  102. (re.compile(r'(^|.* )https?://[^ ]+'),
  103. "Unlinkified link - use [http://foo.bar Link title] or [http://foo.bar]"),
  104. ],
  105. }
  106. def check_regexes(app):
  107. for f, checks in regex_checks.items():
  108. for m, r in checks:
  109. v = app.get_field(f)
  110. t = metadata.fieldtype(f)
  111. if t == metadata.TYPE_MULTILINE:
  112. for l in v.splitlines():
  113. if m.match(l):
  114. yield "%s at line '%s': %s" % (f, l, r)
  115. else:
  116. if v is None:
  117. continue
  118. if m.match(v):
  119. yield "%s '%s': %s" % (f, v, r)
  120. def get_lastbuild(builds):
  121. lowest_vercode = -1
  122. lastbuild = None
  123. for build in builds:
  124. if not build.disable:
  125. vercode = int(build.vercode)
  126. if lowest_vercode == -1 or vercode < lowest_vercode:
  127. lowest_vercode = vercode
  128. if not lastbuild or int(build.vercode) > int(lastbuild.vercode):
  129. lastbuild = build
  130. return lastbuild
  131. def check_ucm_tags(app):
  132. lastbuild = get_lastbuild(app.builds)
  133. if (lastbuild is not None
  134. and lastbuild.commit
  135. and app.UpdateCheckMode == 'RepoManifest'
  136. and not lastbuild.commit.startswith('unknown')
  137. and lastbuild.vercode == app.CurrentVersionCode
  138. and not lastbuild.forcevercode
  139. and any(s in lastbuild.commit for s in '.,_-/')):
  140. yield "Last used commit '%s' looks like a tag, but Update Check Mode is '%s'" % (
  141. lastbuild.commit, app.UpdateCheckMode)
  142. def check_char_limits(app):
  143. limits = config['char_limits']
  144. if len(app.Summary) > limits['Summary']:
  145. yield "Summary of length %s is over the %i char limit" % (
  146. len(app.Summary), limits['Summary'])
  147. if len(app.Description) > limits['Description']:
  148. yield "Description of length %s is over the %i char limit" % (
  149. len(app.Description), limits['Description'])
  150. def check_old_links(app):
  151. usual_sites = [
  152. 'github.com',
  153. 'gitlab.com',
  154. 'bitbucket.org',
  155. ]
  156. old_sites = [
  157. 'gitorious.org',
  158. 'code.google.com',
  159. ]
  160. if any(s in app.Repo for s in usual_sites):
  161. for f in ['Web Site', 'Source Code', 'Issue Tracker', 'Changelog']:
  162. v = app.get_field(f)
  163. if any(s in v for s in old_sites):
  164. yield "App is in '%s' but has a link to '%s'" % (app.Repo, v)
  165. def check_useless_fields(app):
  166. if app.UpdateCheckName == app.id:
  167. yield "Update Check Name is set to the known app id - it can be removed"
  168. filling_ucms = re.compile(r'^(Tags.*|RepoManifest.*)')
  169. def check_checkupdates_ran(app):
  170. if filling_ucms.match(app.UpdateCheckMode):
  171. if not app.AutoName and not app.CurrentVersion and app.CurrentVersionCode == '0':
  172. yield "UCM is set but it looks like checkupdates hasn't been run yet"
  173. def check_empty_fields(app):
  174. if not app.Categories:
  175. yield "Categories are not set"
  176. all_categories = set([
  177. "Connectivity",
  178. "Development",
  179. "Games",
  180. "Graphics",
  181. "Internet",
  182. "Money",
  183. "Multimedia",
  184. "Navigation",
  185. "Phone & SMS",
  186. "Reading",
  187. "Science & Education",
  188. "Security",
  189. "Sports & Health",
  190. "System",
  191. "Theming",
  192. "Time",
  193. "Writing",
  194. ])
  195. def check_categories(app):
  196. for categ in app.Categories:
  197. if categ not in all_categories:
  198. yield "Category '%s' is not valid" % categ
  199. def check_duplicates(app):
  200. if app.Name and app.Name == app.AutoName:
  201. yield "Name '%s' is just the auto name - remove it" % app.Name
  202. links_seen = set()
  203. for f in ['Source Code', 'Web Site', 'Issue Tracker', 'Changelog']:
  204. v = app.get_field(f)
  205. if not v:
  206. continue
  207. v = v.lower()
  208. if v in links_seen:
  209. yield "Duplicate link in '%s': %s" % (f, v)
  210. else:
  211. links_seen.add(v)
  212. name = app.Name or app.AutoName
  213. if app.Summary and name:
  214. if app.Summary.lower() == name.lower():
  215. yield "Summary '%s' is just the app's name" % app.Summary
  216. if app.Summary and app.Description and len(app.Description) == 1:
  217. if app.Summary.lower() == app.Description[0].lower():
  218. yield "Description '%s' is just the app's summary" % app.Summary
  219. seenlines = set()
  220. for l in app.Description.splitlines():
  221. if len(l) < 1:
  222. continue
  223. if l in seenlines:
  224. yield "Description has a duplicate line"
  225. seenlines.add(l)
  226. desc_url = re.compile(r'(^|[^[])\[([^ ]+)( |\]|$)')
  227. def check_mediawiki_links(app):
  228. wholedesc = ' '.join(app.Description)
  229. for um in desc_url.finditer(wholedesc):
  230. url = um.group(1)
  231. for m, r in http_checks:
  232. if m.match(url):
  233. yield "URL '%s' in Description: %s" % (url, r)
  234. def check_bulleted_lists(app):
  235. validchars = ['*', '#']
  236. lchar = ''
  237. lcount = 0
  238. for l in app.Description.splitlines():
  239. if len(l) < 1:
  240. lcount = 0
  241. continue
  242. if l[0] == lchar and l[1] == ' ':
  243. lcount += 1
  244. if lcount > 2 and lchar not in validchars:
  245. yield "Description has a list (%s) but it isn't bulleted (*) nor numbered (#)" % lchar
  246. break
  247. else:
  248. lchar = l[0]
  249. lcount = 1
  250. def check_builds(app):
  251. for build in app.builds:
  252. if build.disable:
  253. if build.disable.startswith('Generated by import.py'):
  254. yield "Build generated by `fdroid import` - remove disable line once ready"
  255. continue
  256. for s in ['master', 'origin', 'HEAD', 'default', 'trunk']:
  257. if build.commit and build.commit.startswith(s):
  258. yield "Branch '%s' used as commit in build '%s'" % (s, build.version)
  259. for srclib in build.srclibs:
  260. ref = srclib.split('@')[1].split('/')[0]
  261. if ref.startswith(s):
  262. yield "Branch '%s' used as commit in srclib '%s'" % (s, srclib)
  263. def check_files_dir(app):
  264. dir_path = os.path.join('metadata', app.id)
  265. if not os.path.isdir(dir_path):
  266. return
  267. files = set()
  268. for name in os.listdir(dir_path):
  269. path = os.path.join(dir_path, name)
  270. if not os.path.isfile(path):
  271. yield "Found non-file at %s" % path
  272. continue
  273. files.add(name)
  274. used = set()
  275. for build in app.builds:
  276. for fname in build.patch:
  277. if fname not in files:
  278. yield "Unknown file %s in build '%s'" % (fname, build.version)
  279. else:
  280. used.add(fname)
  281. for name in files.difference(used):
  282. yield "Unused file at %s" % os.path.join(dir_path, name)
  283. def check_format(app):
  284. if options.format and not rewritemeta.proper_format(app):
  285. yield "Run rewritemeta to fix formatting"
  286. def check_extlib_dir(apps):
  287. dir_path = os.path.join('build', 'extlib')
  288. files = set()
  289. for root, dirs, names in os.walk(dir_path):
  290. for name in names:
  291. files.add(os.path.join(root, name)[len(dir_path) + 1:])
  292. used = set()
  293. for app in apps:
  294. for build in app.builds:
  295. for path in build.extlibs:
  296. if path not in files:
  297. yield "%s: Unknown extlib %s in build '%s'" % (app.id, path, build.version)
  298. else:
  299. used.add(path)
  300. for path in files.difference(used):
  301. if any(path.endswith(s) for s in [
  302. '.gitignore',
  303. 'source.txt', 'origin.txt', 'md5.txt',
  304. 'LICENSE', 'LICENSE.txt',
  305. 'COPYING', 'COPYING.txt',
  306. 'NOTICE', 'NOTICE.txt',
  307. ]):
  308. continue
  309. yield "Unused extlib at %s" % os.path.join(dir_path, path)
  310. def main():
  311. global config, options
  312. # Parse command line...
  313. parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
  314. common.setup_global_opts(parser)
  315. parser.add_argument("-f", "--format", action="store_true", default=False,
  316. help="Also warn about formatting issues, like rewritemeta -l")
  317. parser.add_argument("appid", nargs='*', help="app-id in the form APPID")
  318. metadata.add_metadata_arguments(parser)
  319. options = parser.parse_args()
  320. metadata.warnings_action = options.W
  321. config = common.read_config(options)
  322. # Get all apps...
  323. allapps = metadata.read_metadata(xref=True)
  324. apps = common.read_app_args(options.appid, allapps, False)
  325. anywarns = False
  326. apps_check_funcs = []
  327. if len(options.appid) == 0:
  328. # otherwise it finds tons of unused extlibs
  329. apps_check_funcs.append(check_extlib_dir)
  330. for check_func in apps_check_funcs:
  331. for warn in check_func(apps.values()):
  332. anywarns = True
  333. print(warn)
  334. for appid, app in apps.items():
  335. if app.Disabled:
  336. continue
  337. app_check_funcs = [
  338. check_regexes,
  339. check_ucm_tags,
  340. check_char_limits,
  341. check_old_links,
  342. check_checkupdates_ran,
  343. check_useless_fields,
  344. check_empty_fields,
  345. check_categories,
  346. check_duplicates,
  347. check_mediawiki_links,
  348. check_bulleted_lists,
  349. check_builds,
  350. check_files_dir,
  351. check_format,
  352. ]
  353. for check_func in app_check_funcs:
  354. for warn in check_func(app):
  355. anywarns = True
  356. print("%s: %s" % (appid, warn))
  357. if anywarns:
  358. sys.exit(1)
  359. if __name__ == "__main__":
  360. main()