chiark / gitweb /
checkupdates: let Tags detect subdir changes
[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         logging.debug("All tags: " + ','.join(tags))
132         if pattern:
133             pat = re.compile(pattern)
134             tags = [tag for tag in tags if pat.match(tag)]
135             logging.debug("Matching tags: " + ','.join(tags))
136
137         if repotype in ('git',):
138             tags = vcs.latesttags(tags, 5)
139             logging.debug("Latest tags: " + ','.join(tags))
140
141         for tag in tags:
142             logging.debug("Check tag: '{0}'".format(tag))
143             vcs.gotorevision(tag)
144
145             for subdir in possible_subdirs(app):
146                 root_dir = os.path.join(build_dir, subdir)
147                 paths = common.manifest_paths(root_dir, flavours)
148                 version, vercode, package = \
149                     common.parse_androidmanifests(paths, app['Update Check Ignore'])
150                 if app_matches_packagename(app, package) and version and vercode:
151                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
152                                   .format(subdir, version, vercode))
153                     if int(vercode) > int(hcode):
154                         hpak = package
155                         htag = tag
156                         hcode = str(int(vercode))
157                         hver = version
158
159         if not hpak:
160             return (None, "Couldn't find package ID", None)
161         if hver:
162             return (hver, hcode, htag)
163         return (None, "Couldn't find any version information", None)
164
165     except VCSException as vcse:
166         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
167         return (None, msg, None)
168     except Exception:
169         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
170         return (None, msg, None)
171
172
173 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
174 # of the source repo. Whether this can be used reliably or not depends on
175 # the development procedures used by the project's developers. Use it with
176 # caution, because it's inappropriate for many projects.
177 # Returns (None, "a message") if this didn't work, or (version, vercode) for
178 # the details of the current version.
179 def check_repomanifest(app, branch=None):
180
181     try:
182
183         if app['Repo Type'] == 'srclib':
184             build_dir = os.path.join('build', 'srclib', app['Repo'])
185             repotype = common.getsrclibvcs(app['Repo'])
186         else:
187             build_dir = os.path.join('build', app['id'])
188             repotype = app['Repo Type']
189
190         # Set up vcs interface and make sure we have the latest code...
191         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
192
193         if repotype == 'git':
194             if branch:
195                 branch = 'origin/' + branch
196             vcs.gotorevision(branch)
197         elif repotype == 'git-svn':
198             vcs.gotorevision(branch)
199         elif repotype == 'hg':
200             vcs.gotorevision(branch)
201         elif repotype == 'bzr':
202             vcs.gotorevision(None)
203
204         root_dir = build_dir
205         flavours = []
206         if len(app['builds']) > 0:
207             if app['builds'][-1]['subdir']:
208                 root_dir = os.path.join(build_dir, app['builds'][-1]['subdir'])
209             if app['builds'][-1]['gradle']:
210                 flavours = app['builds'][-1]['gradle']
211
212         if not os.path.isdir(root_dir):
213             return (None, "Subdir '" + app['builds'][-1]['subdir'] + "'is not a valid directory")
214
215         paths = common.manifest_paths(root_dir, flavours)
216
217         version, vercode, package = \
218             common.parse_androidmanifests(paths, app['Update Check Ignore'])
219         if not package:
220             return (None, "Couldn't find package ID")
221         if not app_matches_packagename(app, package):
222             return (None, "Package ID mismatch - got {0}".format(package))
223         if not version:
224             return (None, "Couldn't find latest version name")
225         if not vercode:
226             if "Ignore" == version:
227                 return (None, "Latest version is ignored")
228             return (None, "Couldn't find latest version code")
229
230         vercode = str(int(vercode))
231
232         logging.debug("Manifest exists. Found version {0} ({1})".format(version, vercode))
233
234         return (version, vercode)
235
236     except VCSException as vcse:
237         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
238         return (None, msg)
239     except Exception:
240         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
241         return (None, msg)
242
243
244 def check_repotrunk(app, branch=None):
245
246     try:
247         if app['Repo Type'] == 'srclib':
248             build_dir = os.path.join('build', 'srclib', app['Repo'])
249             repotype = common.getsrclibvcs(app['Repo'])
250         else:
251             build_dir = os.path.join('build', app['id'])
252             repotype = app['Repo Type']
253
254         if repotype not in ('git-svn', ):
255             return (None, 'RepoTrunk update mode only makes sense in git-svn repositories')
256
257         # Set up vcs interface and make sure we have the latest code...
258         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
259
260         vcs.gotorevision(None)
261
262         ref = vcs.getref()
263         return (ref, ref)
264     except VCSException as vcse:
265         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
266         return (None, msg)
267     except Exception:
268         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
269         return (None, msg)
270
271
272 # Check for a new version by looking at the Google Play Store.
273 # Returns (None, "a message") if this didn't work, or (version, None) for
274 # the details of the current version.
275 def check_gplay(app):
276     time.sleep(15)
277     url = 'https://play.google.com/store/apps/details?id=' + app['id']
278     headers = {'User-Agent': 'Mozilla/5.0 (X11; Linux i686; rv:18.0) Gecko/20100101 Firefox/18.0'}
279     req = urllib2.Request(url, None, headers)
280     try:
281         resp = urllib2.urlopen(req, None, 20)
282         page = resp.read()
283     except urllib2.HTTPError, e:
284         return (None, str(e.code))
285     except Exception, e:
286         return (None, 'Failed:' + str(e))
287
288     version = None
289
290     m = re.search('itemprop="softwareVersion">[ ]*([^<]+)[ ]*</div>', page)
291     if m:
292         html_parser = HTMLParser.HTMLParser()
293         version = html_parser.unescape(m.group(1))
294
295     if version == 'Varies with device':
296         return (None, 'Device-variable version, cannot use this method')
297
298     if not version:
299         return (None, "Couldn't find version")
300     return (version.strip(), None)
301
302
303 # Return all directories under startdir that contain any of the manifest
304 # files, and thus are probably an Android project.
305 def dirs_with_manifest(startdir):
306     for r, d, f in os.walk(startdir):
307         if any(m in f for m in [
308                 'AndroidManifest.xml', 'pom.xml', 'build.gradle']):
309             yield r
310
311
312 # Tries to find a new subdir starting from the root build_dir. Returns said
313 # subdir relative to the build dir if found, None otherwise.
314 def possible_subdirs(app):
315
316     if app['Repo Type'] == 'srclib':
317         build_dir = os.path.join('build', 'srclib', app['Repo'])
318     else:
319         build_dir = os.path.join('build', app['id'])
320
321     flavours = []
322     if len(app['builds']) > 0:
323         build = app['builds'][-1]
324         if build['gradle']:
325             flavours = build['gradle']
326         subdir = build['subdir']
327         if subdir and os.path.isdir(os.path.join(build_dir, subdir)):
328             logging.debug("Adding possible subdir %s" % subdir)
329             yield subdir
330
331     for d in dirs_with_manifest(build_dir):
332         m_paths = common.manifest_paths(d, flavours)
333         package = common.parse_androidmanifests(m_paths, app['Update Check Ignore'])[2]
334         if app_matches_packagename(app, package):
335             subdir = os.path.relpath(d, build_dir)
336             if subdir == '.':
337                 continue
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 build['vercode'] == 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()