chiark / gitweb /
f154b4a36a5a164236f6e191fec35b3af052b58b
[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
337     apps = common.read_app_args(args, allapps, False)
338
339     if options.gplay:
340         for app in apps:
341             version, reason = check_gplay(app)
342             if version is None:
343                 if reason == '404':
344                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
345                 else:
346                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
347             if version is not None:
348                 stored = app['Current Version']
349                 if not stored:
350                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
351                                  .format(common.getappname(app), version))
352                 elif LooseVersion(stored) < LooseVersion(version):
353                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
354                                  .format(common.getappname(app), version, stored))
355                 else:
356                     if stored != version:
357                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
358                                      .format(common.getappname(app), version, stored))
359                     else:
360                         logging.info("{0} has the same version {1} on the Play Store"
361                                      .format(common.getappname(app), version))
362         return
363
364     for app in apps:
365
366         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
367             logging.debug("Nothing to do for {0}...".format(app['id']))
368             continue
369
370         logging.info("Processing " + app['id'] + '...')
371
372         # If a change is made, commitmsg should be set to a description of it.
373         # Only if this is set will changes be written back to the metadata.
374         commitmsg = None
375
376         tag = None
377         msg = None
378         vercode = None
379         noverok = False
380         mode = app['Update Check Mode']
381         if mode.startswith('Tags'):
382             pattern = mode[5:] if len(mode) > 4 else None
383             (version, vercode, tag) = check_tags(app, pattern)
384             msg = vercode
385         elif mode == 'RepoManifest':
386             (version, vercode) = check_repomanifest(app)
387             msg = vercode
388         elif mode.startswith('RepoManifest/'):
389             tag = mode[13:]
390             (version, vercode) = check_repomanifest(app, tag)
391             msg = vercode
392         elif mode == 'RepoTrunk':
393             (version, vercode) = check_repotrunk(app)
394             msg = vercode
395         elif mode == 'HTTP':
396             (version, vercode) = check_http(app)
397             msg = vercode
398         elif mode in ('None', 'Static'):
399             version = None
400             msg = 'Checking disabled'
401             noverok = True
402         else:
403             version = None
404             msg = 'Invalid update check method'
405
406         if vercode and app['Vercode Operation']:
407             op = app['Vercode Operation'].replace("%c", str(int(vercode)))
408             vercode = str(eval(op))
409
410         updating = False
411         if not version:
412             logmsg = "...{0} : {1}".format(app['id'], msg)
413             if noverok:
414                 logging.info(logmsg)
415             else:
416                 logging.warn(logmsg)
417         elif vercode == app['Current Version Code']:
418             logging.info("...up to date")
419         else:
420             app['Current Version'] = version
421             app['Current Version Code'] = str(int(vercode))
422             updating = True
423
424         # Do the Auto Name thing as well as finding the CV real name
425         if len(app["Repo Type"]) > 0 and mode not in ('None', 'Static'):
426
427             try:
428
429                 if app['Repo Type'] == 'srclib':
430                     app_dir = os.path.join('build', 'srclib', app['Repo'])
431                 else:
432                     app_dir = os.path.join('build/', app['id'])
433
434                 vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
435                 vcs.gotorevision(tag)
436
437                 flavour = None
438                 if len(app['builds']) > 0:
439                     if app['builds'][-1]['subdir']:
440                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
441                     if app['builds'][-1]['gradle']:
442                         flavour = app['builds'][-1]['gradle']
443                 if flavour == 'yes':
444                     flavour = None
445
446                 logging.debug("...fetch auto name from " + app_dir +
447                               ((" (flavour: %s)" % flavour) if flavour else ""))
448                 new_name = common.fetch_real_name(app_dir, flavour)
449                 if new_name:
450                     logging.debug("...got autoname '" + new_name + "'")
451                     if new_name != app['Auto Name']:
452                         app['Auto Name'] = new_name
453                         if not commitmsg:
454                             commitmsg = "Set autoname of {0}".format(common.getappname(app))
455                 else:
456                     logging.debug("...couldn't get autoname")
457
458                 if app['Current Version'].startswith('@string/'):
459                     cv = common.version_name(app['Current Version'], app_dir, flavour)
460                     if app['Current Version'] != cv:
461                         app['Current Version'] = cv
462                         if not commitmsg:
463                             commitmsg = "Fix CV of {0}".format(common.getappname(app))
464             except Exception:
465                 logging.error("Auto Name or Current Version failed for {0} due to exception: {1}".format(app['id'], traceback.format_exc()))
466
467         if updating:
468             name = common.getappname(app)
469             ver = common.getcvname(app)
470             logging.info('...updating to version %s' % ver)
471             commitmsg = 'Update CV of %s to %s' % (name, ver)
472
473         if options.auto:
474             mode = app['Auto Update Mode']
475             if mode in ('None', 'Static'):
476                 pass
477             elif mode.startswith('Version '):
478                 pattern = mode[8:]
479                 if pattern.startswith('+'):
480                     try:
481                         suffix, pattern = pattern.split(' ', 1)
482                     except ValueError:
483                         raise MetaDataException("Invalid AUM: " + mode)
484                 else:
485                     suffix = ''
486                 gotcur = False
487                 latest = None
488                 for build in app['builds']:
489                     if build['vercode'] == app['Current Version Code']:
490                         gotcur = True
491                     if not latest or int(build['vercode']) > int(latest['vercode']):
492                         latest = build
493
494                 if not gotcur:
495                     newbuild = latest.copy()
496                     if 'origlines' in newbuild:
497                         del newbuild['origlines']
498                     newbuild['disable'] = False
499                     newbuild['vercode'] = app['Current Version Code']
500                     newbuild['version'] = app['Current Version'] + suffix
501                     logging.info("...auto-generating build for " + newbuild['version'])
502                     commit = pattern.replace('%v', newbuild['version'])
503                     commit = commit.replace('%c', newbuild['vercode'])
504                     newbuild['commit'] = commit
505                     app['builds'].append(newbuild)
506                     name = common.getappname(app)
507                     ver = common.getcvname(app)
508                     commitmsg = "Update %s to %s" % (name, ver)
509             else:
510                 logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
511
512         if commitmsg:
513             metafile = os.path.join('metadata', app['id'] + '.txt')
514             metadata.write_metadata(metafile, app)
515             if options.commit:
516                 logging.info("Commiting update for " + metafile)
517                 gitcmd = ["git", "commit", "-m", commitmsg]
518                 if 'auto_author' in config:
519                     gitcmd.extend(['--author', config['auto_author']])
520                 gitcmd.extend(["--", metafile])
521                 if subprocess.call(gitcmd) != 0:
522                     logging.error("Git commit failed")
523                     sys.exit(1)
524
525     logging.info("Finished.")
526
527 if __name__ == "__main__":
528     main()