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