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