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