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