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