chiark / gitweb /
Clarify non-auto-update error message
[fdroidserver.git] / fdroidserver / checkupdates.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # checkupdates.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 # Copyright (C) 2013-2014 Daniel Martí <mvdan@mvdan.cc>
7 #
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12 #
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16 # GNU Affero General Public License for more details.
17 #
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21 import sys
22 import os
23 import re
24 import urllib2
25 import time
26 import subprocess
27 from optparse import OptionParser
28 import traceback
29 import HTMLParser
30 from distutils.version import LooseVersion
31 import logging
32
33 import common, metadata
34 from common import BuildException
35 from common import VCSException
36 from metadata import MetaDataException
37
38
39 # Check for a new version by looking at a document retrieved via HTTP.
40 # The app's Update Check Data field is used to provide the information
41 # required.
42 def check_http(app):
43
44     try:
45
46         if not 'Update Check Data' in app:
47             raise Exception('Missing Update Check Data')
48
49         urlcode, codeex, urlver, verex = app['Update Check Data'].split('|')
50
51         vercode = "99999999"
52         if len(urlcode) > 0:
53             logging.debug("...requesting {0}".format(urlcode))
54             req = urllib2.Request(urlcode, None)
55             resp = urllib2.urlopen(req, None, 20)
56             page = resp.read()
57
58             m = re.search(codeex, page)
59             if not m:
60                 raise Exception("No RE match for version code")
61             vercode = m.group(1)
62
63         version = "??"
64         if len(urlver) > 0:
65             if urlver != '.':
66                 logging.debug("...requesting {0}".format(urlver))
67                 req = urllib2.Request(urlver, None)
68                 resp = urllib2.urlopen(req, None, 20)
69                 page = resp.read()
70
71             m = re.search(verex, page)
72             if not m:
73                 raise Exception("No RE match for version")
74             version = m.group(1)
75
76         return (version, vercode)
77
78     except Exception:
79         msg = "Could not complete http check for app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
80         return (None, msg)
81
82 # Check for a new version by looking at the tags in the source repo.
83 # Whether this can be used reliably or not depends on
84 # the development procedures used by the project's developers. Use it with
85 # caution, because it's inappropriate for many projects.
86 # Returns (None, "a message") if this didn't work, or (version, vercode) for
87 # the details of the current version.
88 def check_tags(app, pattern):
89
90     try:
91
92         if app['Repo Type'] == 'srclib':
93             build_dir = os.path.join('build', 'srclib', app['Repo'])
94             repotype = common.getsrclibvcs(app['Repo'])
95         else:
96             build_dir = os.path.join('build/', app['id'])
97             repotype = app['Repo Type']
98
99         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
100             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
101
102         # Set up vcs interface and make sure we have the latest code...
103         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
104
105         vcs.gotorevision(None)
106
107         flavour = None
108         if len(app['builds']) > 0:
109             if 'subdir' in app['builds'][-1]:
110                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
111             if 'gradle' in app['builds'][-1]:
112                 flavour = app['builds'][-1]['gradle']
113
114         htag = None
115         hver = None
116         hcode = "0"
117
118         tags = vcs.gettags()
119         if pattern:
120             pat = re.compile(pattern)
121             tags = [tag for tag in tags if pat.match(tag)]
122
123         for tag in tags:
124             logging.debug("Check tag: '{0}'".format(tag))
125             vcs.gotorevision(tag)
126
127             # Only process tags where the manifest exists...
128             paths = common.manifest_paths(build_dir, flavour)
129             version, vercode, package = common.parse_androidmanifests(paths)
130             if package and package == app['id'] and version and vercode:
131                 logging.debug("Manifest exists. Found version %s (%s)" % (
132                         version, vercode))
133                 if int(vercode) > int(hcode):
134                     htag = tag
135                     hcode = str(int(vercode))
136                     hver = version
137
138         if hver:
139             return (hver, hcode, htag)
140         return (None, "Couldn't find any version information", None)
141
142     except BuildException as be:
143         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
144         return (None, msg, None)
145     except VCSException as vcse:
146         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
147         return (None, msg, None)
148     except Exception:
149         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
150         return (None, msg, None)
151
152 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
153 # of the source repo. Whether this can be used reliably or not depends on
154 # the development procedures used by the project's developers. Use it with
155 # caution, because it's inappropriate for many projects.
156 # Returns (None, "a message") if this didn't work, or (version, vercode) for
157 # the details of the current version.
158 def check_repomanifest(app, branch=None):
159
160     try:
161
162         if app['Repo Type'] == 'srclib':
163             build_dir = os.path.join('build', 'srclib', app['Repo'])
164             repotype = common.getsrclibvcs(app['Repo'])
165         else:
166             build_dir = os.path.join('build/', app['id'])
167             repotype = app['Repo Type']
168
169         # Set up vcs interface and make sure we have the latest code...
170         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
171
172         if repotype == 'git':
173             if branch:
174                 branch = 'origin/'+branch
175             vcs.gotorevision(branch)
176         elif repotype == 'git-svn':
177             vcs.gotorevision(branch)
178         elif repotype == 'svn':
179             vcs.gotorevision(None)
180         elif repotype == 'hg':
181             vcs.gotorevision(branch)
182         elif repotype == 'bzr':
183             vcs.gotorevision(None)
184
185         flavour = None
186
187         if len(app['builds']) > 0:
188             if 'subdir' in app['builds'][-1]:
189                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
190             if 'gradle' in app['builds'][-1]:
191                 flavour = app['builds'][-1]['gradle']
192
193         if not os.path.isdir(build_dir):
194             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
195
196         paths = common.manifest_paths(build_dir, flavour)
197
198         version, vercode, package = common.parse_androidmanifests(paths)
199         if not package:
200             return (None, "Couldn't find package ID")
201         if package != app['id']:
202             return (None, "Package ID mismatch")
203         if not version:
204             return (None,"Couldn't find latest version name")
205         if not vercode:
206             return (None,"Couldn't find latest version code")
207
208         vercode = str(int(vercode))
209
210         logging.debug("Manifest exists. Found version %s (%s)" % (version, vercode))
211
212         return (version, vercode)
213
214     except BuildException as be:
215         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
216         return (None, msg)
217     except VCSException as vcse:
218         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
219         return (None, msg)
220     except Exception:
221         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
222         return (None, msg)
223
224 def check_repotrunk(app, branch=None):
225
226     try:
227         if app['Repo Type'] == 'srclib':
228             build_dir = os.path.join('build', 'srclib', app['Repo'])
229             repotype = common.getsrclibvcs(app['Repo'])
230         else:
231             build_dir = os.path.join('build/', app['id'])
232             repotype = app['Repo Type']
233
234         if repotype not in ('svn', 'git-svn'):
235             return (None, 'RepoTrunk update mode only makes sense in svn and git-svn repositories')
236
237         # Set up vcs interface and make sure we have the latest code...
238         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
239
240         vcs.gotorevision(None)
241
242         ref = vcs.getref()
243         return (ref, ref)
244     except BuildException as be:
245         msg = "Could not scan app %s due to BuildException: %s" % (app['id'], be)
246         return (None, msg)
247     except VCSException as vcse:
248         msg = "VCS error while scanning app %s: %s" % (app['id'], vcse)
249         return (None, msg)
250     except Exception:
251         msg = "Could not scan app %s due to unknown error: %s" % (app['id'], traceback.format_exc())
252         return (None, msg)
253
254 # Check for a new version by looking at the Google Play Store.
255 # Returns (None, "a message") if this didn't work, or (version, None) for
256 # the details of the current version.
257 def check_gplay(app):
258     time.sleep(15)
259     url = 'https://play.google.com/store/apps/details?id=' + app['id']
260     headers = {'User-Agent' : 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
261     req = urllib2.Request(url, None, headers)
262     try:
263         resp = urllib2.urlopen(req, None, 20)
264         page = resp.read()
265     except urllib2.HTTPError, e:
266         return (None, str(e.code))
267     except Exception, e:
268         return (None, 'Failed:' + str(e))
269
270     version = None
271
272     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
273     if m:
274         html_parser = HTMLParser.HTMLParser()
275         version = html_parser.unescape(m.group(1))
276
277     if version == 'Varies with device':
278         return (None, 'Device-variable version, cannot use this method')
279
280     if not version:
281         return (None, "Couldn't find version")
282     return (version.strip(), None)
283
284
285 config = None
286 options = None
287
288 def main():
289
290     global config, options
291
292     # Parse command line...
293     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
294     parser.add_option("-v", "--verbose", action="store_true", default=False,
295                       help="Spew out even more information than normal")
296     parser.add_option("-q", "--quiet", action="store_true", default=False,
297                       help="Restrict output to warnings and errors")
298     parser.add_option("--auto", action="store_true", default=False,
299                       help="Process auto-updates")
300     parser.add_option("--autoonly", action="store_true", default=False,
301                       help="Only process apps with auto-updates")
302     parser.add_option("--commit", action="store_true", default=False,
303                       help="Commit changes")
304     parser.add_option("--gplay", action="store_true", default=False,
305                       help="Only print differences with the Play Store")
306     (options, args) = parser.parse_args()
307
308     config = common.read_config(options)
309
310     # Get all apps...
311     allapps = metadata.read_metadata(options.verbose)
312
313     apps = common.read_app_args(args, allapps, False)
314
315     if options.gplay:
316         for app in apps:
317             version, reason = check_gplay(app)
318             if version is None:
319                 if reason == '404':
320                     logging.info("%s is not in the Play Store" % common.getappname(app))
321                 else:
322                     logging.info("%s encountered a problem: %s" % (common.getappname(app), reason))
323             if version is not None:
324                 stored = app['Current Version']
325                 if not stored:
326                     logging.info("%s has no Current Version but has version %s on the Play Store" % (
327                             common.getappname(app), version))
328                 elif LooseVersion(stored) < LooseVersion(version):
329                     logging.info("%s has version %s on the Play Store, which is bigger than %s" % (
330                             common.getappname(app), version, stored))
331                 else:
332                     if stored != version:
333                         logging.info("%s has version %s on the Play Store, which differs from %s" % (
334                                 common.getappname(app), version, stored))
335                     else:
336                         logging.info("%s has the same version %s on the Play Store" % (
337                                 common.getappname(app), version))
338         return
339
340
341     for app in apps:
342
343         if options.autoonly and app['Auto Update Mode'] == 'None':
344             logging.debug("Nothing to do for %s..." % app['id'])
345             continue
346
347         logging.info("Processing " + app['id'] + '...')
348
349         writeit = False
350         logmsg = None
351
352         tag = None
353         msg = None
354         vercode = None
355         noverok = False
356         mode = app['Update Check Mode']
357         if mode.startswith('Tags'):
358             pattern = mode[5:] if len(mode) > 4 else None
359             (version, vercode, tag) = check_tags(app, pattern)
360         elif mode == 'RepoManifest':
361             (version, vercode) = check_repomanifest(app)
362         elif mode.startswith('RepoManifest/'):
363             tag = mode[13:]
364             (version, vercode) = check_repomanifest(app, tag)
365         elif mode == 'RepoTrunk':
366             (version, vercode) = check_repotrunk(app)
367         elif mode == 'HTTP':
368             (version, vercode) = check_http(app)
369         elif mode == 'Static':
370             version = None
371             msg = 'Checking disabled'
372             noverok = True
373         elif mode == 'None':
374             version = None
375             msg = 'Checking disabled'
376             noverok = True
377         else:
378             version = None
379             msg = 'Invalid update check method'
380
381         if vercode and app['Vercode Operation']:
382             op = app['Vercode Operation'].replace("%c", str(int(vercode)))
383             vercode = str(eval(op))
384
385         updating = False
386         if not version:
387             logmsg = "...{0} : {1}".format(app['id'], msg)
388             if noverok:
389                 logging.info(logmsg)
390             else:
391                 logging.warn(logmsg)
392         elif vercode == app['Current Version Code']:
393             logging.info("...up to date")
394         else:
395             app['Current Version'] = version
396             app['Current Version Code'] = str(int(vercode))
397             updating = True
398             writeit = True
399
400         # Do the Auto Name thing as well as finding the CV real name
401         if len(app["Repo Type"]) > 0 and mode != 'None':
402
403             try:
404
405                 if app['Repo Type'] == 'srclib':
406                     app_dir = os.path.join('build', 'srclib', app['Repo'])
407                 else:
408                     app_dir = os.path.join('build/', app['id'])
409
410                 vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
411                 vcs.gotorevision(tag)
412
413                 flavour = None
414                 if len(app['builds']) > 0:
415                     if 'subdir' in app['builds'][-1]:
416                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
417                     if 'gradle' in app['builds'][-1]:
418                         flavour = app['builds'][-1]['gradle']
419
420                 new_name = common.fetch_real_name(app_dir, flavour)
421                 if new_name != app['Auto Name']:
422                     app['Auto Name'] = new_name
423                     writeit = True
424
425                 if app['Current Version'].startswith('@string/'):
426                     cv = common.version_name(app['Current Version'], app_dir, flavour)
427                     if app['Current Version'] != cv:
428                         app['Current Version'] = cv
429                         writeit = True
430             except Exception:
431                 logging.error("Auto Name or Current Version failed for %s due to exception: %s" % (app['id'], traceback.format_exc()))
432
433         if updating:
434             name = common.getappname(app)
435             ver = common.getcvname(app)
436             logging.info('...updating to version %s' % ver)
437             logmsg = 'Update CV of %s to %s' % (name, ver)
438
439         if options.auto:
440             mode = app['Auto Update Mode']
441             if mode == 'None':
442                 pass
443             elif mode.startswith('Version '):
444                 pattern = mode[8:]
445                 if pattern.startswith('+'):
446                     try:
447                         suffix, pattern = pattern.split(' ', 1)
448                     except ValueError:
449                         raise MetaDataException("Invalid AUM: " + mode)
450                 else:
451                     suffix = ''
452                 gotcur = False
453                 latest = None
454                 for build in app['builds']:
455                     if build['vercode'] == app['Current Version Code']:
456                         gotcur = True
457                     if not latest or int(build['vercode']) > int(latest['vercode']):
458                         latest = build
459
460                 if 'disable' in latest:
461                     logging.warn('Not auto-updating %s since the latest build is disabled' % app['id'])
462                 elif not gotcur:
463                     newbuild = latest.copy()
464                     if 'origlines' in newbuild:
465                         del newbuild['origlines']
466                     newbuild['vercode'] = app['Current Version Code']
467                     newbuild['version'] = app['Current Version'] + suffix
468                     logging.info("...auto-generating build for " + newbuild['version'])
469                     commit = pattern.replace('%v', newbuild['version'])
470                     commit = commit.replace('%c', newbuild['vercode'])
471                     newbuild['commit'] = commit
472                     app['builds'].append(newbuild)
473                     writeit = True
474                     name = common.getappname(app)
475                     ver = common.getcvname(app)
476                     logmsg = "Update %s to %s" % (name, ver)
477             else:
478                 logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
479
480         if writeit:
481             metafile = os.path.join('metadata', app['id'] + '.txt')
482             metadata.write_metadata(metafile, app)
483             if options.commit and logmsg:
484                 logging.info("Commiting update for " + metafile)
485                 gitcmd = ["git", "commit", "-m",
486                     logmsg]
487                 if 'auto_author' in config:
488                     gitcmd.extend(['--author', config['auto_author']])
489                 gitcmd.extend(["--", metafile])
490                 if subprocess.call(gitcmd) != 0:
491                     logging.error("Git commit failed")
492                     sys.exit(1)
493
494     logging.info("Finished.")
495
496 if __name__ == "__main__":
497     main()
498