calibre/setup/hosting.py

537 lines
18 KiB
Python

#!/usr/bin/env python2
# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai
from __future__ import absolute_import, division, print_function, unicode_literals
__license__ = 'GPL v3'
__copyright__ = '2011, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en'
import os, time, sys, shutil, json, mimetypes
from pprint import pprint
from argparse import ArgumentParser, FileType
from subprocess import check_call
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.items():
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><link rel="icon" type="image/png" href="//calibre-ebook.com/favicon.png" /> <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.items():
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/'}.items():
# 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')
#
# }}}
# 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')
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)
if __name__ == '__main__':
main()
# }}}