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