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