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