#!/usr/bin/env python # 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 ' __docformat__ = 'restructuredtext en' import os, time, sys, traceback, subprocess, urllib2, re, base64, httplib, shutil, glob, json, mimetypes from pprint import pprint from argparse import ArgumentParser, FileType from subprocess import check_call, CalledProcessError, check_output from tempfile import NamedTemporaryFile from collections import OrderedDict def login_to_google(username, password): # {{{ import mechanize br = mechanize.Browser() br.addheaders = [('User-agent', 'Mozilla/5.0 (X11; Linux x86_64; rv:9.0) Gecko/20100101 Firefox/9.0')] br.set_handle_robots(False) br.open('https://accounts.google.com/ServiceLogin?service=code') br.select_form(nr=0) br.form['Email'] = username br.form['Passwd'] = password raw = br.submit().read() if re.search(br'(?i).*?Account Settings', raw) is None: x = re.search(br'(?is).*?', raw) if x is not None: print ('Title of post login page: %s'%x.group()) # open('/tmp/goog.html', 'wb').write(raw) raise ValueError(('Failed to login to google with credentials: %s %s' '\nGoogle sometimes requires verification when logging in from a ' 'new IP address. Use lynx to login and supply the verification, ' 'at: lynx -accept_all_cookies https://accounts.google.com/ServiceLogin?service=code') %(username, password)) return br # }}} 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 GoogleCode(Base): # {{{ def __init__(self, # A mapping of filenames to file descriptions. The descriptions are # used to populate the description field for the upload on google # code files, # The unix name for the application. appname, # The version being uploaded version, # Google account username username, # Googlecode.com password password, # Google account password gmail_password, # The name of the google code project we are uploading to gc_project, # Server to which to upload the mapping of file names to google # code URLs. If not None, upload is performed via shelling out to # ssh, so you must have ssh-agent setup with the authenticated key # and ssh agent forwarding enabled gpaths_server=None, # The path on gpaths_server to which to upload the mapping data gpaths=None, # If True, files are replaced, otherwise existing files are skipped reupload=False, # The pattern to match filenames for the files being uploaded and # extract version information from them. Must have a named group # named version filename_pattern=r'{appname}-(?:portable-installer-)?(?P.+?)(?:-(?:i686|x86_64|32bit|64bit))?\.(?:zip|exe|msi|dmg|tar\.bz2|tar\.xz|txz|tbz2)' # noqa ): self.username, self.password, = username, password self.gmail_password, self.gc_project = gmail_password, gc_project self.reupload, self.files, self.version = reupload, files, version self.gpaths, self.gpaths_server = gpaths, gpaths_server self.upload_host = '%s.googlecode.com'%gc_project self.files_list = 'http://code.google.com/p/%s/downloads/list'%gc_project self.delete_url = 'http://code.google.com/p/%s/downloads/delete?name=%%s'%gc_project self.filename_pat = re.compile(filename_pattern.format(appname=appname)) for x in self.files: if self.filename_pat.match(os.path.basename(x)) is None: raise ValueError(('The filename %s does not match the ' 'filename pattern')%os.path.basename(x)) def upload_one(self, fname, retries=2): self.info('\nUploading', fname) typ = 'Type-' + ('Source' if fname.endswith('.xz') else 'Archive' if fname.endswith('.zip') else 'Installer') ext = os.path.splitext(fname)[1][1:] op = 'OpSys-'+{'msi':'Windows','exe':'Windows', 'dmg':'OSX','txz':'Linux','xz':'All'}[ext] desc = self.files[fname] start = time.time() for i in range(retries): try: path = self.upload(os.path.abspath(fname), desc, labels=[typ, op, 'Featured'], retry=100) except KeyboardInterrupt: raise SystemExit(1) except: traceback.print_exc() print ('\nUpload failed, trying again in 30 secs.', '%d retries left.'%(retries-1)) time.sleep(30) else: break self.info('Uploaded to:', path, 'in', int(time.time() - start), 'seconds') return path def re_upload(self): fnames = {os.path.basename(x):x for x in self.files} existing = self.old_files.intersection(set(fnames)) br = self.login_to_google() for x, src in fnames.iteritems(): if not os.access(src, os.R_OK): continue if x in existing: self.info('Deleting', x) br.open(self.delete_url%x) br.select_form(predicate=lambda y: 'delete.do' in y.action) br.form.find_control(name='delete') br.submit(name='delete') self.upload_one(src) def __call__(self): self.paths = {} self.old_files = self.get_old_files() if self.reupload: return self.re_upload() for fname in self.files: bname = os.path.basename(fname) if bname in self.old_files: path = 'http://%s.googlecode.com/files/%s'%(self.gc_project, bname) self.info( '%s already uploaded, skipping. Assuming URL is: %s'%( bname, path)) self.old_files.remove(bname) else: path = self.upload_one(fname) self.paths[bname] = path self.info('Updating path map') for k, v in self.paths.iteritems(): self.info('\t%s => %s'%(k, v)) if self.gpaths and self.gpaths_server: raw = subprocess.Popen(['ssh', self.gpaths_server, 'cat', self.gpaths], stdout=subprocess.PIPE).stdout.read() paths = eval(raw) if raw else {} paths.update(self.paths) rem = [x for x in paths if self.version not in x] for x in rem: paths.pop(x) raw = ['%r : %r,'%(k, v) for k, v in paths.items()] raw = '{\n\n%s\n\n}\n'%('\n'.join(raw)) with NamedTemporaryFile() as t: t.write(raw) t.flush() check_call(['scp', t.name, '%s:%s'%(self.gpaths_server, self.gpaths)]) if self.old_files: self.br = self.login_to_google() self.delete_old_files() def login_to_google(self): self.info('Logging into Google') return login_to_google(self.username, self.gmail_password) def get_files_hosted_by_google_code(self): from lxml import html self.info('Getting existing files in google code:', self.gc_project) raw = urllib2.urlopen(self.files_list).read() root = html.fromstring(raw) ans = {} for a in root.xpath('//td[@class="vt id col_0"]/a[@href]'): ans[a.text.strip()] = a.get('href') return ans def get_old_files(self): ans = set() for fname in self.get_files_hosted_by_google_code(): m = self.filename_pat.match(fname) if m is not None: ans.add(fname) return ans def delete_old_files(self): if not self.old_files: return self.info('Deleting old files from Google Code...') for fname in self.old_files: self.info('\tDeleting', fname) self.br.open(self.delete_url%fname) self.br.select_form(predicate=lambda x: 'delete.do' in x.action) self.br.form.find_control(name='delete') self.br.submit(name='delete') def encode_upload_request(self, fields, file_path): BOUNDARY = '----------Googlecode_boundary_reindeer_flotilla' body = [] # Add the metadata about the upload first for key, value in fields: body.extend( ['--' + BOUNDARY, 'Content-Disposition: form-data; name="%s"' % key, '', value, ]) # Now add the file itself file_name = os.path.basename(file_path) with open(file_path, 'rb') as f: file_content = f.read() body.extend( ['--' + BOUNDARY, 'Content-Disposition: form-data; name="filename"; filename="%s"' % file_name, # The upload server determines the mime-type, no need to set it. 'Content-Type: application/octet-stream', '', file_content, ]) # Finalize the form body body.extend(['--' + BOUNDARY + '--', '']) body = [x.encode('ascii') if isinstance(x, unicode) else x for x in body] return ('multipart/form-data; boundary=%s' % BOUNDARY, b'\r\n'.join(body)) def upload(self, fname, desc, labels=[], retry=0): form_fields = [('summary', desc)] form_fields.extend([('label', l.strip()) for l in labels]) content_type, body = self.encode_upload_request(form_fields, fname) upload_uri = '/files' auth_token = base64.b64encode('%s:%s'% (self.username, self.password)) headers = { 'Authorization': 'Basic %s' % auth_token, 'User-Agent': 'googlecode.com uploader v1', 'Content-Type': content_type, } with NamedTemporaryFile(delete=False) as f: f.write(body) try: body = ReadFileWithProgressReporting(f.name) server = httplib.HTTPSConnection(self.upload_host) server.request('POST', upload_uri, body, headers) resp = server.getresponse() server.close() finally: os.remove(f.name) if resp.status == 201: return resp.getheader('Location') print ('Failed to upload with code %d and reason: %s'%(resp.status, resp.reason)) if retry < 1: print ('Retrying in 5 seconds....') time.sleep(5) return self.upload(fname, desc, labels=labels, retry=retry+1) raise Exception('Failed to upload '+fname) # }}} 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) 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): release = self.create_release() 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) 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 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 create_release(self): ' Create a release on GitHub or if it already exists, return the existing release ' url = self.API + 'repos/%s/%s/releases' % (self.username, self.reponame) r = self.requests.post(url, data=json.dumps({ 'tag_name':'v%s' % self.version, 'target_commitish': 'master', 'name': 'version %s' % self.version, 'body': 'Release version %s' % self.version, 'draft': False, 'prerelease':False })) if r.status_code != 201: if not self.already_exists(r): self.fail(r, 'Failed to create release for version: %s' % self.version) # Find existing release r = self.requests.get(url) if r.status_code != 200: self.fail(r, 'Failed to list releases') for release in reversed(r.json()): if release.get('tag_name', None) == 'v' + self.version: return release 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 = '''\n {title}

{title}

{msg}

{body} ''' # 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('
  • {0}.x\xa0\xa0\xa0[{1} releases]
  • '.format( # noqa '.'.join(map(type(''), series)), len(rmap[series]))) body = ''.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 = [ '
  • {0}
  • '.format('.'.join(map(type(''), r))) for r in releases] body = ''.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 = ['
  • {1}
  • '.format( x, 'Windows 64-bit Installer' if '64bit' in x else 'Windows 32-bit Installer') for x in windows] body.append('
    Windows
      {0}
    '.format(' '.join(windows))) portable = [x for x in files if '-portable-' in x] if portable: body.append('
    Calibre Portable
    {1}
    '.format( portable[0], 'Calibre Portable Installer')) osx = [x for x in files if x.endswith('.dmg')] if osx: body.append('
    Apple Mac
    {1}
    '.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 = ['
  • {1}
  • '.format( x, 'Linux 64-bit binary' if 'x86_64' in x else 'Linux 32-bit binary') for x in linux] body.append('
    Linux
      {0}
    '.format(' '.join(linux))) source = [x for x in files if x.endswith('.xz') or x.endswith('.gz')] if source: body.append('
    Source Code
    {1}
    '.format( source[0], 'Source code (all platforms)')) body = '
    {0}
    '.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') gc = subparsers.add_parser('googlecode', help='Upload to googlecode', epilog=epilog) sf = subparsers.add_parser('sourceforge', help='Upload to sourceforge', epilog=epilog) gh = subparsers.add_parser('github', help='Upload to GitHub', epilog=epilog) cron = subparsers.add_parser('cron', help='Call script from cron') subparsers.add_parser('calibre', help='Upload to calibre file servers') subparsers.add_parser('dbs', help='Upload to fosshub.com') a = gc.add_argument a('project', help='The name of the project on google code we are uploading to') a('username', help='Username to log into your google account') a('password', help='Password to log into your google account') a('gc_password', help='Password for google code hosting.' ' Get it from http://code.google.com/hosting/settings') a('--path-map-server', help='A server to which the mapping of filenames to googlecode ' 'URLs will be uploaded. The upload happens via ssh, so you must ' 'have a working ssh agent') a('--path-map-location', help='Path on the server where the path map is placed.') a = sf.add_argument a('project', help='The name of the project on sourceforge we are uploading to') a('username', help='Sourceforge username') a = cron.add_argument a('username', help='Username to log into your google account') a('password', help='Password to log into your google account') 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 = {} if args.service != 'cron': 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 == 'googlecode': gc = GoogleCode(ofiles, args.appname, args.version, args.username, args.gc_password, args.password, args.project, gpaths_server=args.path_map_server, gpaths=args.path_map_location, reupload=args.replace) gc() elif 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 == 'cron': login_to_google(args.username, args.password) elif args.service == 'calibre': upload_to_servers(ofiles, args.version) elif args.service == 'dbs': upload_to_dbs(ofiles, args.version) if __name__ == '__main__': main() # }}}