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