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