chiark / gitweb /
fix PEP8 "E302 expected 2 blank lines, found 1"
[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
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         # Set up vcs interface and make sure we have the latest code...
106         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
107
108         vcs.gotorevision(None)
109
110         flavour = None
111         if len(app['builds']) > 0:
112             if 'subdir' in app['builds'][-1]:
113                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
114             if 'gradle' in app['builds'][-1]:
115                 flavour = app['builds'][-1]['gradle']
116         if flavour == 'yes':
117             flavour = None
118
119         htag = None
120         hver = None
121         hcode = "0"
122
123         tags = vcs.gettags()
124         if pattern:
125             pat = re.compile(pattern)
126             tags = [tag for tag in tags if pat.match(tag)]
127
128         if repotype in ('git',):
129             tags = vcs.latesttags(tags, 5)
130
131         for tag in tags:
132             logging.debug("Check tag: '{0}'".format(tag))
133             vcs.gotorevision(tag)
134
135             # Only process tags where the manifest exists...
136             paths = common.manifest_paths(build_dir, flavour)
137             version, vercode, package = common.parse_androidmanifests(paths)
138             if not package or package != appid or not version or not vercode:
139                 continue
140
141             logging.debug("Manifest exists. Found version {0} ({1})".format(
142                     version, vercode))
143             if int(vercode) > int(hcode):
144                 htag = tag
145                 hcode = str(int(vercode))
146                 hver = version
147
148         if hver:
149             return (hver, hcode, htag)
150         return (None, "Couldn't find any version information", None)
151
152     except BuildException as be:
153         msg = "Could not scan app {0} due to BuildException: {1}".format(app['id'], be)
154         return (None, msg, None)
155     except VCSException as vcse:
156         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
157         return (None, msg, None)
158     except Exception:
159         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
160         return (None, msg, None)
161
162
163 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
164 # of the source repo. Whether this can be used reliably or not depends on
165 # the development procedures used by the project's developers. Use it with
166 # caution, because it's inappropriate for many projects.
167 # Returns (None, "a message") if this didn't work, or (version, vercode) for
168 # the details of the current version.
169 def check_repomanifest(app, branch=None):
170
171     try:
172
173         appid = app['Update Check Name'] if app['Update Check Name'] else app['id']
174         if app['Repo Type'] == 'srclib':
175             build_dir = os.path.join('build', 'srclib', app['Repo'])
176             repotype = common.getsrclibvcs(app['Repo'])
177         else:
178             build_dir = os.path.join('build/', app['id'])
179             repotype = app['Repo Type']
180
181         # Set up vcs interface and make sure we have the latest code...
182         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
183
184         if repotype == 'git':
185             if branch:
186                 branch = 'origin/'+branch
187             vcs.gotorevision(branch)
188         elif repotype == 'git-svn':
189             vcs.gotorevision(branch)
190         elif repotype == 'svn':
191             vcs.gotorevision(None)
192         elif repotype == 'hg':
193             vcs.gotorevision(branch)
194         elif repotype == 'bzr':
195             vcs.gotorevision(None)
196
197         flavour = None
198
199         if len(app['builds']) > 0:
200             if 'subdir' in app['builds'][-1]:
201                 build_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
202             if 'gradle' in app['builds'][-1]:
203                 flavour = app['builds'][-1]['gradle']
204         if flavour == 'yes':
205             flavour = None
206
207         if not os.path.isdir(build_dir):
208             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
209
210         paths = common.manifest_paths(build_dir, flavour)
211
212         version, vercode, package = common.parse_androidmanifests(paths)
213         if not package:
214             return (None, "Couldn't find package ID")
215         if package != appid:
216             return (None, "Package ID mismatch")
217         if not version:
218             return (None, "Couldn't find latest version name")
219         if not vercode:
220             return (None, "Couldn't find latest version code")
221
222         vercode = str(int(vercode))
223
224         logging.debug("Manifest exists. Found version {0} ({1})".format(version, vercode))
225
226         return (version, vercode)
227
228     except BuildException as be:
229         msg = "Could not scan app {0} due to BuildException: {1}".format(app['id'], be)
230         return (None, msg)
231     except VCSException as vcse:
232         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
233         return (None, msg)
234     except Exception:
235         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
236         return (None, msg)
237
238
239 def check_repotrunk(app, branch=None):
240
241     try:
242         if app['Repo Type'] == 'srclib':
243             build_dir = os.path.join('build', 'srclib', app['Repo'])
244             repotype = common.getsrclibvcs(app['Repo'])
245         else:
246             build_dir = os.path.join('build/', app['id'])
247             repotype = app['Repo Type']
248
249         if repotype not in ('svn', 'git-svn'):
250             return (None, 'RepoTrunk update mode only makes sense in svn and git-svn repositories')
251
252         # Set up vcs interface and make sure we have the latest code...
253         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
254
255         vcs.gotorevision(None)
256
257         ref = vcs.getref()
258         return (ref, ref)
259     except BuildException as be:
260         msg = "Could not scan app {0} due to BuildException: {1}".format(app['id'], be)
261         return (None, msg)
262     except VCSException as vcse:
263         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
264         return (None, msg)
265     except Exception:
266         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
267         return (None, msg)
268
269
270 # Check for a new version by looking at the Google Play Store.
271 # Returns (None, "a message") if this didn't work, or (version, None) for
272 # the details of the current version.
273 def check_gplay(app):
274     time.sleep(15)
275     url = 'https://play.google.com/store/apps/details?id=' + app['id']
276     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
277     req = urllib2.Request(url, None, headers)
278     try:
279         resp = urllib2.urlopen(req, None, 20)
280         page = resp.read()
281     except urllib2.HTTPError, e:
282         return (None, str(e.code))
283     except Exception, e:
284         return (None, 'Failed:' + str(e))
285
286     version = None
287
288     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
289     if m:
290         html_parser = HTMLParser.HTMLParser()
291         version = html_parser.unescape(m.group(1))
292
293     if version == 'Varies with device':
294         return (None, 'Device-variable version, cannot use this method')
295
296     if not version:
297         return (None, "Couldn't find version")
298     return (version.strip(), None)
299
300
301 config = None
302 options = None
303
304
305 def main():
306
307     global config, options
308
309     # Parse command line...
310     parser = OptionParser(usage="Usage: %prog [options] [APPID [APPID ...]]")
311     parser.add_option("-v", "--verbose", action="store_true", default=False,
312                       help="Spew out even more information than normal")
313     parser.add_option("-q", "--quiet", action="store_true", default=False,
314                       help="Restrict output to warnings and errors")
315     parser.add_option("--auto", action="store_true", default=False,
316                       help="Process auto-updates")
317     parser.add_option("--autoonly", action="store_true", default=False,
318                       help="Only process apps with auto-updates")
319     parser.add_option("--commit", action="store_true", default=False,
320                       help="Commit changes")
321     parser.add_option("--gplay", action="store_true", default=False,
322                       help="Only print differences with the Play Store")
323     (options, args) = parser.parse_args()
324
325     config = common.read_config(options)
326
327     # Get all apps...
328     allapps = metadata.read_metadata(options.verbose)
329
330     apps = common.read_app_args(args, allapps, False)
331
332     if options.gplay:
333         for app in apps:
334             version, reason = check_gplay(app)
335             if version is None:
336                 if reason == '404':
337                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
338                 else:
339                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
340             if version is not None:
341                 stored = app['Current Version']
342                 if not stored:
343                     logging.info("{0} has no Current Version but has version {1} on the Play Store".format(
344                             common.getappname(app), version))
345                 elif LooseVersion(stored) < LooseVersion(version):
346                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}".format(
347                             common.getappname(app), version, stored))
348                 else:
349                     if stored != version:
350                         logging.info("{0} has version {1} on the Play Store, which differs from {2}".format(
351                                 common.getappname(app), version, stored))
352                     else:
353                         logging.info("{0} has the same version {1} on the Play Store".format(
354                                 common.getappname(app), version))
355         return
356
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 'subdir' in app['builds'][-1]:
434                         app_dir = os.path.join(app_dir, app['builds'][-1]['subdir'])
435                     if 'gradle' in app['builds'][-1]:
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                     for k in ('origlines', 'disable'):
491                         if k in newbuild:
492                             del newbuild[k]
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",
512                     commitmsg]
513                 if 'auto_author' in config:
514                     gitcmd.extend(['--author', config['auto_author']])
515                 gitcmd.extend(["--", metafile])
516                 if subprocess.call(gitcmd) != 0:
517                     logging.error("Git commit failed")
518                     sys.exit(1)
519
520     logging.info("Finished.")
521
522 if __name__ == "__main__":
523     main()
524