chiark / gitweb /
vcs_* stuff should not raise BuildExceptions
[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 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         hpak = None
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                 hpak = package
149                 htag = tag
150                 hcode = str(int(vercode))
151                 hver = version
152
153         if not hpak:
154             return (None, "Couldn't find package ID", None)
155         if hver:
156             return (hver, hcode, htag)
157         return (None, "Couldn't find any version information", None)
158
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 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 VCSException as vcse:
264         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
265         return (None, msg)
266     except Exception:
267         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
268         return (None, msg)
269
270
271 # Check for a new version by looking at the Google Play Store.
272 # Returns (None, "a message") if this didn't work, or (version, None) for
273 # the details of the current version.
274 def check_gplay(app):
275     time.sleep(15)
276     url = 'https://play.google.com/store/apps/details?id=' + app['id']
277     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
278     req = urllib2.Request(url, None, headers)
279     try:
280         resp = urllib2.urlopen(req, None, 20)
281         page = resp.read()
282     except urllib2.HTTPError, e:
283         return (None, str(e.code))
284     except Exception, e:
285         return (None, 'Failed:' + str(e))
286
287     version = None
288
289     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
290     if m:
291         html_parser = HTMLParser.HTMLParser()
292         version = html_parser.unescape(m.group(1))
293
294     if version == 'Varies with device':
295         return (None, 'Device-variable version, cannot use this method')
296
297     if not version:
298         return (None, "Couldn't find version")
299     return (version.strip(), None)
300
301
302 config = None
303 options = None
304
305
306 def main():
307
308     global config, options
309
310     # Parse command line...
311     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
312     parser.add_option("-v", "--verbose", action="store_true", default=False,
313                       help="Spew out even more information than normal")
314     parser.add_option("-q", "--quiet", action="store_true", default=False,
315                       help="Restrict output to warnings and errors")
316     parser.add_option("--auto", action="store_true", default=False,
317                       help="Process auto-updates")
318     parser.add_option("--autoonly", action="store_true", default=False,
319                       help="Only process apps with auto-updates")
320     parser.add_option("--commit", action="store_true", default=False,
321                       help="Commit changes")
322     parser.add_option("--gplay", action="store_true", default=False,
323                       help="Only print differences with the Play Store")
324     (options, args) = parser.parse_args()
325
326     config = common.read_config(options)
327
328     # Get all apps...
329     allapps = metadata.read_metadata()
330
331     apps = common.read_app_args(args, allapps, False)
332
333     if options.gplay:
334         for app in apps:
335             version, reason = check_gplay(app)
336             if version is None:
337                 if reason == '404':
338                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
339                 else:
340                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
341             if version is not None:
342                 stored = app['Current Version']
343                 if not stored:
344                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
345                                  .format(common.getappname(app), version))
346                 elif LooseVersion(stored) < LooseVersion(version):
347                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
348                                  .format(common.getappname(app), version, stored))
349                 else:
350                     if stored != version:
351                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
352                                      .format(common.getappname(app), version, stored))
353                     else:
354                         logging.info("{0} has the same version {1} on the Play Store"
355                                      .format(common.getappname(app), version))
356         return
357
358     for app in apps:
359
360         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
361             logging.debug("Nothing to do for {0}...".format(app['id']))
362             continue
363
364         logging.info("Processing " + app['id'] + '...')
365
366         # If a change is made, commitmsg should be set to a description of it.
367         # Only if this is set will changes be written back to the metadata.
368         commitmsg = None
369
370         tag = None
371         msg = None
372         vercode = None
373         noverok = False
374         mode = app['Update Check Mode']
375         if mode.startswith('Tags'):
376             pattern = mode[5:] if len(mode) > 4 else None
377             (version, vercode, tag) = check_tags(app, pattern)
378             msg = vercode
379         elif mode == 'RepoManifest':
380             (version, vercode) = check_repomanifest(app)
381             msg = vercode
382         elif mode.startswith('RepoManifest/'):
383             tag = mode[13:]
384             (version, vercode) = check_repomanifest(app, tag)
385             msg = vercode
386         elif mode == 'RepoTrunk':
387             (version, vercode) = check_repotrunk(app)
388             msg = vercode
389         elif mode == 'HTTP':
390             (version, vercode) = check_http(app)
391             msg = vercode
392         elif mode in ('None', 'Static'):
393             version = None
394             msg = 'Checking disabled'
395             noverok = True
396         else:
397             version = None
398             msg = 'Invalid update check method'
399
400         if vercode and app['Vercode Operation']:
401             op = app['Vercode Operation'].replace("%c", str(int(vercode)))
402             vercode = str(eval(op))
403
404         updating = False
405         if not version:
406             logmsg = "...{0} : {1}".format(app['id'], msg)
407             if noverok:
408                 logging.info(logmsg)
409             else:
410                 logging.warn(logmsg)
411         elif vercode == app['Current Version Code']:
412             logging.info("...up to date")
413         else:
414             app['Current Version'] = version
415             app['Current Version Code'] = str(int(vercode))
416             updating = True
417
418         # Do the Auto Name thing as well as finding the CV real name
419         if len(app["Repo Type"]) > 0 and mode not in ('None', 'Static'):
420
421             try:
422
423                 if app['Repo Type'] == 'srclib':
424                     app_dir = os.path.join('build', 'srclib', app['Repo'])
425                 else:
426                     app_dir = os.path.join('build/', app['id'])
427
428                 vcs = common.getvcs(app["Repo Type"], app["Repo"], app_dir)
429                 vcs.gotorevision(tag)
430
431                 flavour = None
432                 if len(app['builds']) > 0:
433                     if app['builds'][-1]['subdir']:
434                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
435                     if app['builds'][-1]['gradle']:
436                         flavour = app['builds'][-1]['gradle']
437                 if flavour == 'yes':
438                     flavour = None
439
440                 logging.debug("...fetch auto name from " + app_dir +
441                               ((" (flavour: %s)" % flavour) if flavour else ""))
442                 new_name = common.fetch_real_name(app_dir, flavour)
443                 if new_name:
444                     logging.debug("...got autoname '" + new_name + "'")
445                     if new_name != app['Auto Name']:
446                         app['Auto Name'] = new_name
447                         if not commitmsg:
448                             commitmsg = "Set autoname of {0}".format(common.getappname(app))
449                 else:
450                     logging.debug("...couldn't get autoname")
451
452                 if app['Current Version'].startswith('@string/'):
453                     cv = common.version_name(app['Current Version'], app_dir, flavour)
454                     if app['Current Version'] != cv:
455                         app['Current Version'] = cv
456                         if not commitmsg:
457                             commitmsg = "Fix CV of {0}".format(common.getappname(app))
458             except Exception:
459                 logging.error("Auto Name or Current Version failed for {0} due to exception: {1}".format(app['id'], traceback.format_exc()))
460
461         if updating:
462             name = common.getappname(app)
463             ver = common.getcvname(app)
464             logging.info('...updating to version %s' % ver)
465             commitmsg = 'Update CV of %s to %s' % (name, ver)
466
467         if options.auto:
468             mode = app['Auto Update Mode']
469             if mode in ('None', 'Static'):
470                 pass
471             elif mode.startswith('Version '):
472                 pattern = mode[8:]
473                 if pattern.startswith('+'):
474                     try:
475                         suffix, pattern = pattern.split(' ', 1)
476                     except ValueError:
477                         raise MetaDataException("Invalid AUM: " + mode)
478                 else:
479                     suffix = ''
480                 gotcur = False
481                 latest = None
482                 for build in app['builds']:
483                     if build['vercode'] == app['Current Version Code']:
484                         gotcur = True
485                     if not latest or int(build['vercode']) > int(latest['vercode']):
486                         latest = build
487
488                 if not gotcur:
489                     newbuild = latest.copy()
490                     if 'origlines' in newbuild:
491                         del newbuild['origlines']
492                     newbuild['disable'] = False
493                     newbuild['vercode'] = app['Current Version Code']
494                     newbuild['version'] = app['Current Version'] + suffix
495                     logging.info("...auto-generating build for " + newbuild['version'])
496                     commit = pattern.replace('%v', newbuild['version'])
497                     commit = commit.replace('%c', newbuild['vercode'])
498                     newbuild['commit'] = commit
499                     app['builds'].append(newbuild)
500                     name = common.getappname(app)
501                     ver = common.getcvname(app)
502                     commitmsg = "Update %s to %s" % (name, ver)
503             else:
504                 logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
505
506         if commitmsg:
507             metafile = os.path.join('metadata', app['id'] + '.txt')
508             metadata.write_metadata(metafile, app)
509             if options.commit:
510                 logging.info("Commiting update for " + metafile)
511                 gitcmd = ["git", "commit", "-m", commitmsg]
512                 if 'auto_author' in config:
513                     gitcmd.extend(['--author', config['auto_author']])
514                 gitcmd.extend(["--", metafile])
515                 if subprocess.call(gitcmd) != 0:
516                     logging.error("Git commit failed")
517                     sys.exit(1)
518
519     logging.info("Finished.")
520
521 if __name__ == "__main__":
522     main()