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