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