chiark / gitweb /
checkupdates: find new subdirs for autonames 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 # 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, tag) for
88 # the details of the current version.
89 def check_tags(app, pattern):
90
91     try:
92
93         if app['Repo Type'] == 'srclib':
94             build_dir = os.path.join('build', 'srclib', app['Repo'])
95             repotype = common.getsrclibvcs(app['Repo'])
96         else:
97             build_dir = os.path.join('build', app['id'])
98             repotype = app['Repo Type']
99
100         if repotype not in ('git', 'git-svn', 'hg', 'bzr'):
101             return (None, 'Tags update mode only works for git, hg, bzr and git-svn repositories currently', None)
102
103         if repotype == 'git-svn' and ';' not in app['Repo']:
104             return (None, 'Tags update mode used in git-svn, but the repo was not set up with tags', None)
105
106         # Set up vcs interface and make sure we have the latest code...
107         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
108
109         vcs.gotorevision(None)
110
111         flavours = []
112         if len(app['builds']) > 0:
113             if app['builds'][-1]['gradle']:
114                 flavours = app['builds'][-1]['gradle']
115
116         hpak = None
117         htag = None
118         hver = None
119         hcode = "0"
120
121         tags = vcs.gettags()
122         if not tags:
123             return (None, "No tags found", None)
124
125         logging.debug("All tags: " + ','.join(tags))
126         if pattern:
127             pat = re.compile(pattern)
128             tags = [tag for tag in tags if pat.match(tag)]
129             if not tags:
130                 return (None, "No matching tags found", None)
131             logging.debug("Matching tags: " + ','.join(tags))
132
133         if len(tags) > 5 and repotype in ('git',):
134             tags = vcs.latesttags(tags, 5)
135             logging.debug("Latest tags: " + ','.join(tags))
136
137         for tag in tags:
138             logging.debug("Check tag: '{0}'".format(tag))
139             vcs.gotorevision(tag)
140
141             for subdir in possible_subdirs(app):
142                 if subdir == '.':
143                     root_dir = build_dir
144                 else:
145                     root_dir = os.path.join(build_dir, subdir)
146                 paths = common.manifest_paths(root_dir, flavours)
147                 version, vercode, package = common.parse_androidmanifests(paths, app)
148                 if vercode:
149                     logging.debug("Manifest exists in subdir '{0}'. Found version {1} ({2})"
150                                   .format(subdir, version, vercode))
151                     if int(vercode) > int(hcode):
152                         hpak = package
153                         htag = tag
154                         hcode = str(int(vercode))
155                         hver = version
156
157         if not hpak:
158             return (None, "Couldn't find package ID", None)
159         if hver:
160             return (hver, hcode, htag)
161         return (None, "Couldn't find any version information", None)
162
163     except VCSException as vcse:
164         msg = "VCS error while scanning app {0}: {1}".format(app['id'], vcse)
165         return (None, msg, None)
166     except Exception:
167         msg = "Could not scan app {0} due to unknown error: {1}".format(app['id'], traceback.format_exc())
168         return (None, msg, None)
169
170
171 # Check for a new version by looking at the AndroidManifest.xml at the HEAD
172 # of the source repo. Whether this can be used reliably or not depends on
173 # the development procedures used by the project's developers. Use it with
174 # caution, because it's inappropriate for many projects.
175 # Returns (None, "a message") if this didn't work, or (version, vercode) for
176 # the details of the current version.
177 def check_repomanifest(app, branch=None):
178
179     try:
180
181         if app['Repo Type'] == 'srclib':
182             build_dir = os.path.join('build', 'srclib', app['Repo'])
183             repotype = common.getsrclibvcs(app['Repo'])
184         else:
185             build_dir = os.path.join('build', app['id'])
186             repotype = app['Repo Type']
187
188         # Set up vcs interface and make sure we have the latest code...
189         vcs = common.getvcs(app['Repo Type'], app['Repo'], build_dir)
190
191         if repotype == 'git':
192             if branch:
193                 branch = 'origin/' + branch
194             vcs.gotorevision(branch)
195         elif repotype == 'git-svn':
196             vcs.gotorevision(branch)
197         elif repotype == 'hg':
198             vcs.gotorevision(branch)
199         elif repotype == 'bzr':
200             vcs.gotorevision(None)
201
202         flavours = []
203         if len(app['builds']) > 0:
204             if app['builds'][-1]['gradle']:
205                 flavours = app['builds'][-1]['gradle']
206
207         hpak = None
208         hver = None
209         hcode = "0"
210         for subdir in possible_subdirs(app):
211             if subdir == '.':
212                 root_dir = build_dir
213             else:
214                 root_dir = os.path.join(build_dir, subdir)
215             paths = common.manifest_paths(root_dir, flavours)
216             version, vercode, package = common.parse_androidmanifests(paths, app)
217             if 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
322     for d in dirs_with_manifest(build_dir):
323         m_paths = common.manifest_paths(d, flavours)
324         package = common.parse_androidmanifests(m_paths, app)[2]
325         if package is not None:
326             subdir = os.path.relpath(d, build_dir)
327             logging.debug("Adding possible subdir %s" % subdir)
328             yield subdir
329
330
331 def fetch_autoname(app, tag):
332
333     if not app["Repo Type"] or app['Update Check Mode'] in ('None', 'Static'):
334         return None
335
336     if app['Repo Type'] == 'srclib':
337         build_dir = os.path.join('build', 'srclib', app['Repo'])
338     else:
339         build_dir = os.path.join('build', app['id'])
340
341     try:
342         vcs = common.getvcs(app["Repo Type"], app["Repo"], build_dir)
343         vcs.gotorevision(tag)
344     except VCSException:
345         return None
346
347     flavours = []
348     if len(app['builds']) > 0:
349         if app['builds'][-1]['gradle']:
350             flavours = app['builds'][-1]['gradle']
351
352     logging.debug("...fetch auto name from " + build_dir)
353     for subdir in possible_subdirs(app):
354         if subdir == '.':
355             root_dir = build_dir
356         else:
357             root_dir = os.path.join(build_dir, subdir)
358         new_name = common.fetch_real_name(root_dir, flavours)
359     commitmsg = None
360     if new_name:
361         logging.debug("...got autoname '" + new_name + "'")
362         if new_name != app['Auto Name']:
363             app['Auto Name'] = new_name
364             if not commitmsg:
365                 commitmsg = "Set autoname of {0}".format(common.getappname(app))
366     else:
367         logging.debug("...couldn't get autoname")
368
369     return commitmsg
370
371
372 def checkupdates_app(app, first=True):
373
374     # If a change is made, commitmsg should be set to a description of it.
375     # Only if this is set will changes be written back to the metadata.
376     commitmsg = None
377
378     tag = None
379     msg = None
380     vercode = None
381     noverok = False
382     mode = app['Update Check Mode']
383     if mode.startswith('Tags'):
384         pattern = mode[5:] if len(mode) > 4 else None
385         (version, vercode, tag) = check_tags(app, pattern)
386         msg = vercode
387     elif mode == 'RepoManifest':
388         (version, vercode) = check_repomanifest(app)
389         msg = vercode
390     elif mode.startswith('RepoManifest/'):
391         tag = mode[13:]
392         (version, vercode) = check_repomanifest(app, tag)
393         msg = vercode
394     elif mode == 'RepoTrunk':
395         (version, vercode) = check_repotrunk(app)
396         msg = vercode
397     elif mode == 'HTTP':
398         (version, vercode) = check_http(app)
399         msg = vercode
400     elif mode in ('None', 'Static'):
401         version = None
402         msg = 'Checking disabled'
403         noverok = True
404     else:
405         version = None
406         msg = 'Invalid update check method'
407
408     if version and vercode and app['Vercode Operation']:
409         oldvercode = str(int(vercode))
410         op = app['Vercode Operation'].replace("%c", oldvercode)
411         vercode = str(eval(op))
412         logging.debug("Applied vercode operation: %s -> %s" % (oldvercode, vercode))
413
414     if version and any(version.startswith(s) for s in [
415             '${',  # Gradle variable names
416             '@string/',  # Strings we could not resolve
417             ]):
418         version = "Unknown"
419
420     updating = False
421     if version is None:
422         logmsg = "...{0} : {1}".format(app['id'], msg)
423         if noverok:
424             logging.info(logmsg)
425         else:
426             logging.warn(logmsg)
427     elif vercode == app['Current Version Code']:
428         logging.info("...up to date")
429     else:
430         app['Current Version'] = version
431         app['Current Version Code'] = str(int(vercode))
432         updating = True
433
434     commitmsg = fetch_autoname(app, tag)
435
436     if updating:
437         name = common.getappname(app)
438         ver = common.getcvname(app)
439         logging.info('...updating to version %s' % ver)
440         commitmsg = 'Update CV of %s to %s' % (name, ver)
441
442     if options.auto:
443         mode = app['Auto Update Mode']
444         if mode in ('None', 'Static'):
445             pass
446         elif mode.startswith('Version '):
447             pattern = mode[8:]
448             if pattern.startswith('+'):
449                 try:
450                     suffix, pattern = pattern.split(' ', 1)
451                 except ValueError:
452                     raise MetaDataException("Invalid AUM: " + mode)
453             else:
454                 suffix = ''
455             gotcur = False
456             latest = None
457             for build in app['builds']:
458                 if int(build['vercode']) >= int(app['Current Version Code']):
459                     gotcur = True
460                 if not latest or int(build['vercode']) > int(latest['vercode']):
461                     latest = build
462
463             if int(latest['vercode']) > int(app['Current Version Code']):
464                 logging.info("Refusing to auto update, since the latest build is newer")
465
466             if not gotcur:
467                 newbuild = latest.copy()
468                 if 'origlines' in newbuild:
469                     del newbuild['origlines']
470                 newbuild['disable'] = False
471                 newbuild['vercode'] = app['Current Version Code']
472                 newbuild['version'] = app['Current Version'] + suffix
473                 logging.info("...auto-generating build for " + newbuild['version'])
474                 commit = pattern.replace('%v', newbuild['version'])
475                 commit = commit.replace('%c', newbuild['vercode'])
476                 newbuild['commit'] = commit
477                 app['builds'].append(newbuild)
478                 name = common.getappname(app)
479                 ver = common.getcvname(app)
480                 commitmsg = "Update %s to %s" % (name, ver)
481         else:
482             logging.warn('Invalid auto update mode "' + mode + '" on ' + app['id'])
483
484     if commitmsg:
485         metadatapath = os.path.join('metadata', app['id'] + '.txt')
486         with open(metadatapath, 'w') as f:
487             metadata.write_metadata('txt', f, app)
488         if options.commit:
489             logging.info("Commiting update for " + metadatapath)
490             gitcmd = ["git", "commit", "-m", commitmsg]
491             if 'auto_author' in config:
492                 gitcmd.extend(['--author', config['auto_author']])
493             gitcmd.extend(["--", metadatapath])
494             if subprocess.call(gitcmd) != 0:
495                 logging.error("Git commit failed")
496                 sys.exit(1)
497
498
499 config = None
500 options = None
501
502
503 def main():
504
505     global config, options
506
507     # Parse command line...
508     parser = ArgumentParser(usage="%(prog)s [options] [APPID [APPID ...]]")
509     common.setup_global_opts(parser)
510     parser.add_argument("appid", nargs='*', help="app-id to check for updates")
511     parser.add_argument("--auto", action="store_true", default=False,
512                         help="Process auto-updates")
513     parser.add_argument("--autoonly", action="store_true", default=False,
514                         help="Only process apps with auto-updates")
515     parser.add_argument("--commit", action="store_true", default=False,
516                         help="Commit changes")
517     parser.add_argument("--gplay", action="store_true", default=False,
518                         help="Only print differences with the Play Store")
519     options = parser.parse_args()
520
521     config = common.read_config(options)
522
523     # Get all apps...
524     allapps = metadata.read_metadata()
525
526     apps = common.read_app_args(options.appid, allapps, False)
527
528     if options.gplay:
529         for app in apps:
530             version, reason = check_gplay(app)
531             if version is None:
532                 if reason == '404':
533                     logging.info("{0} is not in the Play Store".format(common.getappname(app)))
534                 else:
535                     logging.info("{0} encountered a problem: {1}".format(common.getappname(app), reason))
536             if version is not None:
537                 stored = app['Current Version']
538                 if not stored:
539                     logging.info("{0} has no Current Version but has version {1} on the Play Store"
540                                  .format(common.getappname(app), version))
541                 elif LooseVersion(stored) < LooseVersion(version):
542                     logging.info("{0} has version {1} on the Play Store, which is bigger than {2}"
543                                  .format(common.getappname(app), version, stored))
544                 else:
545                     if stored != version:
546                         logging.info("{0} has version {1} on the Play Store, which differs from {2}"
547                                      .format(common.getappname(app), version, stored))
548                     else:
549                         logging.info("{0} has the same version {1} on the Play Store"
550                                      .format(common.getappname(app), version))
551         return
552
553     for appid, app in apps.iteritems():
554
555         if options.autoonly and app['Auto Update Mode'] in ('None', 'Static'):
556             logging.debug("Nothing to do for {0}...".format(appid))
557             continue
558
559         logging.info("Processing " + appid + '...')
560
561         checkupdates_app(app)
562
563     logging.info("Finished.")
564
565 if __name__ == "__main__":
566     main()