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