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