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