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