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