calibre/setup/hosting.py

471 lines
19 KiB
Python

#!/usr/bin/env python2
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import (unicode_literals, division, absolute_import,
print_function)
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os, time, sys, shutil, glob, json, mimetypes
from pprint import pprint
from argparse import ArgumentParser, FileType
from subprocess import check_call, CalledProcessError, check_output
from collections import OrderedDict
class ReadFileWithProgressReporting(file): # {{{
def __init__(self, path, mode='rb'):
file.__init__(self, path, mode)
self.seek(0, os.SEEK_END)
self._total = self.tell()
self.seek(0)
self.start_time = time.time()
def __len__(self):
return self._total
def read(self, size):
data = file.read(self, size)
if data:
self.report_progress(len(data))
return data
def report_progress(self, size):
sys.stdout.write(b'\x1b[s')
sys.stdout.write(b'\x1b[K')
frac = float(self.tell())/self._total
mb_pos = self.tell()/float(1024**2)
mb_tot = self._total/float(1024**2)
kb_pos = self.tell()/1024.0
kb_rate = kb_pos/(time.time()-self.start_time)
bit_rate = kb_rate * 1024
eta = int((self._total - self.tell())/bit_rate) + 1
eta_m, eta_s = eta / 60, eta % 60
sys.stdout.write(
' %.1f%% %.1f/%.1fMB %.1f KB/sec %d minutes, %d seconds left'%(
frac*100, mb_pos, mb_tot, kb_rate, eta_m, eta_s))
sys.stdout.write(b'\x1b[u')
if self.tell() >= self._total:
sys.stdout.write('\n')
t = int(time.time() - self.start_time) + 1
print ('Upload took %d minutes and %d seconds at %.1f KB/sec' % (
t/60, t%60, kb_rate))
sys.stdout.flush()
# }}}
class Base(object): # {{{
def __init__(self):
self.d = os.path.dirname
self.j = os.path.join
self.a = os.path.abspath
self.b = os.path.basename
self.s = os.path.splitext
self.e = os.path.exists
def info(self, *args, **kwargs):
print(*args, **kwargs)
sys.stdout.flush()
def warn(self, *args, **kwargs):
print('\n'+'_'*20, 'WARNING','_'*20)
print(*args, **kwargs)
print('_'*50)
sys.stdout.flush()
# }}}
class SourceForge(Base): # {{{
# Note that you should manually ssh once to username,project@frs.sourceforge.net
# on the staging server so that the host key is setup
def __init__(self, files, project, version, username, replace=False):
self.username, self.project, self.version = username, project, version
self.base = '/home/frs/project/c/ca/'+project
self.rdir = self.base + '/' + version
self.files = files
def __call__(self):
for x in self.files:
start = time.time()
self.info('Uploading', x)
for i in range(5):
try:
check_call(['rsync', '-h', '-z', '--progress', '-e', 'ssh -x', x,
'%s,%s@frs.sourceforge.net:%s'%(self.username, self.project,
self.rdir+'/')])
except KeyboardInterrupt:
raise SystemExit(1)
except:
print ('\nUpload failed, trying again in 30 seconds')
time.sleep(30)
else:
break
print ('Uploaded in', int(time.time() - start), 'seconds\n\n')
# }}}
class GitHub(Base): # {{{
API = 'https://api.github.com/'
def __init__(self, files, reponame, version, username, password, replace=False):
self.files, self.reponame, self.version, self.username, self.password, self.replace = (
files, reponame, version, username, password, replace)
self.current_tag_name = 'v' + self.version
import requests
self.requests = s = requests.Session()
s.auth = (self.username, self.password)
s.headers.update({'Accept': 'application/vnd.github.v3+json'})
def __call__(self):
releases = self.releases()
self.clean_older_releases(releases)
release = self.create_release(releases)
upload_url = release['upload_url'].partition('{')[0]
existing_assets = self.existing_assets(release['id'])
for path, desc in self.files.iteritems():
self.info('')
url = self.API + 'repos/%s/%s/releases/assets/{}' % (self.username, self.reponame)
fname = os.path.basename(path)
if fname in existing_assets:
self.info('Deleting %s from GitHub with id: %s' % (fname, existing_assets[fname]))
r = self.requests.delete(url.format(existing_assets[fname]))
if r.status_code != 204:
self.fail(r, 'Failed to delete %s from GitHub' % fname)
r = self.do_upload(upload_url, path, desc, fname)
if r.status_code != 201:
self.fail(r, 'Failed to upload file: %s' % fname)
try:
r = self.requests.patch(url.format(r.json()['id']),
data=json.dumps({'name':fname, 'label':desc}))
except Exception:
time.sleep(15)
r = self.requests.patch(url.format(r.json()['id']),
data=json.dumps({'name':fname, 'label':desc}))
if r.status_code != 200:
self.fail(r, 'Failed to set label for %s' % fname)
def clean_older_releases(self, releases):
for release in releases:
if release.get('assets', None) and release['tag_name'] != self.current_tag_name:
self.info('\nDeleting old released installers from: %s' % release['tag_name'])
for asset in release['assets']:
r = self.requests.delete(self.API + 'repos/%s/%s/releases/assets/%s' % (self.username, self.reponame, asset['id']))
if r.status_code != 204:
self.fail(r, 'Failed to delete obsolete asset: %s for release: %s' % (
asset['name'], release['tag_name']))
def do_upload(self, url, path, desc, fname):
mime_type = mimetypes.guess_type(fname)[0]
self.info('Uploading to GitHub: %s (%s)' % (fname, mime_type))
with ReadFileWithProgressReporting(path) as f:
return self.requests.post(
url, headers={'Content-Type': mime_type, 'Content-Length':str(f._total)}, params={'name':fname},
data=f)
def fail(self, r, msg):
print (msg, ' Status Code: %s' % r.status_code, file=sys.stderr)
print ("JSON from response:", file=sys.stderr)
pprint(dict(r.json()), stream=sys.stderr)
raise SystemExit(1)
def already_exists(self, r):
error_code = r.json().get('errors', [{}])[0].get('code', None)
return error_code == 'already_exists'
def existing_assets(self, release_id):
url = self.API + 'repos/%s/%s/releases/%s/assets' % (self.username, self.reponame, release_id)
r = self.requests.get(url)
if r.status_code != 200:
self.fail('Failed to get assets for release')
return {asset['name']:asset['id'] for asset in r.json()}
def releases(self):
url = self.API + 'repos/%s/%s/releases' % (self.username, self.reponame)
r = self.requests.get(url)
if r.status_code != 200:
self.fail(r, 'Failed to list releases')
return r.json()
def create_release(self, releases):
' Create a release on GitHub or if it already exists, return the existing release '
for release in releases:
# Check for existing release
if release['tag_name'] == self.current_tag_name:
return release
url = self.API + 'repos/%s/%s/releases' % (self.username, self.reponame)
r = self.requests.post(url, data=json.dumps({
'tag_name': self.current_tag_name,
'target_commitish': 'master',
'name': 'version %s' % self.version,
'body': 'Release version %s' % self.version,
'draft': False, 'prerelease':False
}))
if r.status_code != 201:
self.fail(r, 'Failed to create release for version: %s' % self.version)
return r.json()
# }}}
def generate_index(): # {{{
os.chdir('/srv/download')
releases = set()
for x in os.listdir('.'):
if os.path.isdir(x) and '.' in x:
releases.add(tuple((int(y) for y in x.split('.'))))
rmap = OrderedDict()
for rnum in sorted(releases, reverse=True):
series = rnum[:2] if rnum[0] == 0 else rnum[:1]
if series not in rmap:
rmap[series] = []
rmap[series].append(rnum)
template = '''<!DOCTYPE html>\n<html lang="en"> <head> <meta charset="utf-8"> <title>{title}</title> <style type="text/css"> {style} </style> </head> <body> <h1>{title}</h1> <p>{msg}</p> {body} </body> </html> ''' # noqa
style = '''
body { font-family: sans-serif; background-color: #eee; }
a { text-decoration: none; }
a:visited { color: blue }
a:hover { color: red }
ul { list-style-type: none }
li { padding-bottom: 1ex }
dd li { text-indent: 0; margin: 0 }
dd ul { padding: 0; margin: 0 }
dt { font-weight: bold }
dd { margin-bottom: 2ex }
'''
body = []
for series in rmap:
body.append('<li><a href="{0}.html" title="Releases in the {0}.x series">{0}.x</a>\xa0\xa0\xa0<span style="font-size:smaller">[{1} releases]</span></li>'.format( # noqa
'.'.join(map(type(''), series)), len(rmap[series])))
body = '<ul>{0}</ul>'.format(' '.join(body))
index = template.format(title='Previous calibre releases', style=style, msg='Choose a series of calibre releases', body=body)
with open('index.html', 'wb') as f:
f.write(index.encode('utf-8'))
for series, releases in rmap.iteritems():
sname = '.'.join(map(type(''), series))
body = [
'<li><a href="{0}/" title="Release {0}">{0}</a></li>'.format('.'.join(map(type(''), r)))
for r in releases]
body = '<ul class="release-list">{0}</ul>'.format(' '.join(body))
index = template.format(title='Previous calibre releases (%s.x)' % sname, style=style,
msg='Choose a calibre release', body=body)
with open('%s.html' % sname, 'wb') as f:
f.write(index.encode('utf-8'))
for r in releases:
rname = '.'.join(map(type(''), r))
os.chdir(rname)
try:
body = []
files = os.listdir('.')
windows = [x for x in files if x.endswith('.msi')]
if windows:
windows = ['<li><a href="{0}" title="{1}">{1}</a></li>'.format(
x, 'Windows 64-bit Installer' if '64bit' in x else 'Windows 32-bit Installer')
for x in windows]
body.append('<dt>Windows</dt><dd><ul>{0}</ul></dd>'.format(' '.join(windows)))
portable = [x for x in files if '-portable-' in x]
if portable:
body.append('<dt>Calibre Portable</dt><dd><a href="{0}" title="{1}">{1}</a></dd>'.format(
portable[0], 'Calibre Portable Installer'))
osx = [x for x in files if x.endswith('.dmg')]
if osx:
body.append('<dt>Apple Mac</dt><dd><a href="{0}" title="{1}">{1}</a></dd>'.format(
osx[0], 'OS X Disk Image (.dmg)'))
linux = [x for x in files if x.endswith('.txz') or x.endswith('tar.bz2')]
if linux:
linux = ['<li><a href="{0}" title="{1}">{1}</a></li>'.format(
x, 'Linux 64-bit binary' if 'x86_64' in x else 'Linux 32-bit binary')
for x in linux]
body.append('<dt>Linux</dt><dd><ul>{0}</ul></dd>'.format(' '.join(linux)))
source = [x for x in files if x.endswith('.xz') or x.endswith('.gz')]
if source:
body.append('<dt>Source Code</dt><dd><a href="{0}" title="{1}">{1}</a></dd>'.format(
source[0], 'Source code (all platforms)'))
body = '<dl>{0}</dl>'.format(''.join(body))
index = template.format(title='calibre release (%s)' % rname, style=style,
msg='', body=body)
with open('index.html', 'wb') as f:
f.write(index.encode('utf-8'))
finally:
os.chdir('..')
# }}}
SERVER_BASE = '/srv/download/'
def upload_to_servers(files, version): # {{{
base = SERVER_BASE
dest = os.path.join(base, version)
if not os.path.exists(dest):
os.mkdir(dest)
for src in files:
shutil.copyfile(src, os.path.join(dest, os.path.basename(src)))
cwd = os.getcwd()
try:
generate_index()
finally:
os.chdir(cwd)
# for server, rdir in {'files':'/srv/download/'}.iteritems():
# print('Uploading to server:', server)
# server = '%s.calibre-ebook.com' % server
# # Copy the generated index files
# print ('Copying generated index')
# check_call(['rsync', '-hza', '-e', 'ssh -x', '--include', '*.html',
# '--filter', '-! */', base, 'root@%s:%s' % (server, rdir)])
# # Copy the release files
# rdir = '%s%s/' % (rdir, version)
# for x in files:
# start = time.time()
# print ('Uploading', x)
# for i in range(5):
# try:
# check_call(['rsync', '-h', '-z', '--progress', '-e', 'ssh -x', x,
# 'root@%s:%s'%(server, rdir)])
# except KeyboardInterrupt:
# raise SystemExit(1)
# except:
# print ('\nUpload failed, trying again in 30 seconds')
# time.sleep(30)
# else:
# break
# print ('Uploaded in', int(time.time() - start), 'seconds\n\n')
#
# }}}
def upload_to_dbs(files, version): # {{{
print('Uploading to fosshub.com')
sys.stdout.flush()
server = 'mirror10.fosshub.com'
rdir = 'release/'
def run_ssh(command, func=check_call):
cmd = ['ssh', '-x', 'kovid@%s' % server, command]
try:
return func(cmd)
except CalledProcessError as err:
# fosshub is being a little flaky sshing into it is failing the first
# time, needing a retry
if err.returncode != 255:
raise
return func(cmd)
old_files = set(run_ssh('ls ' + rdir, func=check_output).decode('utf-8').split())
if len(files) < 7:
existing = set(map(os.path.basename, files))
# fosshub does not support partial re-uploads
for f in glob.glob('%s/%s/calibre-*' % (SERVER_BASE, version)):
if os.path.basename(f) not in existing:
files[f] = None
for x in files:
start = time.time()
print ('Uploading', x)
sys.stdout.flush()
old_files.discard(os.path.basename(x))
for i in range(5):
try:
check_call(['rsync', '-h', '-z', '--progress', '-e', 'ssh -x', x,
'kovid@%s:%s'%(server, rdir)])
except KeyboardInterrupt:
raise SystemExit(1)
except:
print ('\nUpload failed, trying again in 30 seconds')
sys.stdout.flush()
time.sleep(30)
else:
break
print ('Uploaded in', int(time.time() - start), 'seconds\n\n')
sys.stdout.flush()
if old_files:
run_ssh('rm -f %s' % (' '.join(rdir + x for x in old_files)))
run_ssh('/home/kovid/uploadFiles')
# }}}
# CLI {{{
def cli_parser():
epilog='Copyright Kovid Goyal 2012'
p = ArgumentParser(
description='Upload project files to a hosting service automatically',
epilog=epilog
)
a = p.add_argument
a('appname', help='The name of the application, all files to'
' upload should begin with this name')
a('version', help='The version of the application, all files to'
' upload should contain this version')
a('file_map', type=FileType('rb'),
help='A file containing a mapping of files to be uploaded to '
'descriptions of the files. The descriptions will be visible '
'to users trying to get the file from the hosting service. '
'The format of the file is filename: description, with one per '
'line. filename can be a path to the file relative to the current '
'directory.')
a('--replace', action='store_true', default=False,
help='If specified, existing files are replaced, otherwise '
'they are skipped.')
subparsers = p.add_subparsers(help='Where to upload to', dest='service',
title='Service', description='Hosting service to upload to')
sf = subparsers.add_parser('sourceforge', help='Upload to sourceforge',
epilog=epilog)
gh = subparsers.add_parser('github', help='Upload to GitHub',
epilog=epilog)
subparsers.add_parser('calibre', help='Upload to calibre file servers')
subparsers.add_parser('dbs', help='Upload to fosshub.com')
a = sf.add_argument
a('project',
help='The name of the project on sourceforge we are uploading to')
a('username',
help='Sourceforge username')
a = gh.add_argument
a('project',
help='The name of the repository on GitHub we are uploading to')
a('username',
help='Username to log into your GitHub account')
a('password',
help='Password to log into your GitHub account')
return p
def main(args=None):
cli = cli_parser()
args = cli.parse_args(args)
files = {}
with args.file_map as f:
for line in f:
fname, _, desc = line.partition(':')
fname, desc = fname.strip(), desc.strip()
if fname and desc:
files[fname] = desc
ofiles = OrderedDict()
for x in sorted(files, key=lambda x:os.stat(x).st_size, reverse=True):
ofiles[x] = files[x]
if args.service == 'sourceforge':
sf = SourceForge(ofiles, args.project, args.version, args.username,
replace=args.replace)
sf()
elif args.service == 'github':
gh = GitHub(ofiles, args.project, args.version, args.username, args.password,
replace=args.replace)
gh()
elif args.service == 'calibre':
upload_to_servers(ofiles, args.version)
elif args.service == 'dbs':
upload_to_dbs(ofiles, args.version)
if __name__ == '__main__':
main()
# }}}