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