diff --git a/.gitignore b/.gitignore index b721d10d99c5ab4424b155b5de02644ff1c395f1..bda232640bf2c3c592479004ecb1c4665bad150a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.pyc /bin/ env/ +.idea diff --git a/klaus/__init__.py b/klaus/__init__.py index dcdd2293559f59b59f94867c8613246602f1a80f..53e0d690f53b7735135ce3fe12afaa5ed89e6708 100644 --- a/klaus/__init__.py +++ b/klaus/__init__.py @@ -56,6 +56,7 @@ class Klaus(flask.Flask): ('history', '/<repo>/tree/<rev>/'), ('history', '/<repo>/tree/<rev>/<path:path>'), ('robots_txt', '/robots.txt/'), + ('download', '/<repo>/tarball/<rev>/'), ]: self.add_url_rule(rule, view_func=getattr(views, endpoint)) diff --git a/klaus/tarutils.py b/klaus/tarutils.py new file mode 100644 index 0000000000000000000000000000000000000000..fcdf7f5f31cb24757410921f99c0cfe892159480 --- /dev/null +++ b/klaus/tarutils.py @@ -0,0 +1,82 @@ +import os +import stat +import tarfile +from io import BytesIO + + +class ListBytesIO(object): + """ + Turns a list of bytestrings into a file-like object. + + This is similar to creating a `BytesIO` from a concatenation of the + bytestring list, but saves memory by NOT creating one giant bytestring first:: + + BytesIO(b''.join(list_of_bytestrings)) =~= ListBytesIO(list_of_bytestrings) + """ + def __init__(self, contents): + self.contents = contents + self.pos = (0, 0) + + def read(self, maxbytes=None): + if maxbytes < 0: + maxbytes = float('inf') + + buf = [] + chunk, cursor = self.pos + + while chunk < len(self.contents): + if maxbytes < len(self.contents[chunk]) - cursor: + buf.append(self.contents[chunk][cursor:cursor+maxbytes]) + cursor += maxbytes + self.pos = (chunk, cursor) + break + else: + buf.append(self.contents[chunk][cursor:]) + maxbytes -= len(self.contents[chunk]) - cursor + chunk += 1 + cursor = 0 + self.pos = (chunk, cursor) + return b''.join(buf) + + +def tar_stream(repo, tree, mtime): + """ + Returns a generator that lazily assembles a .tar.gz archive, yielding it in + pieces (bytestrings). To obtain the complete .tar.gz binary file, simply + concatenate these chunks. + + 'repo' and 'tree' are the dulwich Repo and Tree objects the archive shall be + created from. 'mtime' is a UNIX timestamp that is assigned as the modification + time of all files in the resulting .tar.gz archive. + """ + buf = BytesIO() + with tarfile.open(None, "w", buf) as tar: + for entry_abspath, entry in walk_tree(repo, tree): + blob = repo[entry.sha] + data = ListBytesIO(blob.chunked) + + info = tarfile.TarInfo() + info.name = entry_abspath + info.size = blob.raw_length() + info.mode = entry.mode + info.mtime = mtime + + tar.addfile(info, data) + yield buf.getvalue() + buf.truncate(0) + buf.seek(0) + yield buf.getvalue() + + +def walk_tree(repo, tree, root=''): + """ + Recursively walk a dulwich Tree, yielding tuples of (absolute path, + TreeEntry) along the way. + """ + for entry in tree.iteritems(): + entry_abspath = os.path.join(root, entry.path) + if stat.S_ISDIR(entry.mode): + for _ in walk_tree(repo, repo[entry.sha], entry_abspath): + yield _ + else: + yield (entry_abspath, entry) diff --git a/klaus/templates/tree.inc.html b/klaus/templates/tree.inc.html index 61853c577c3e8fbec06a29ff3594f41bfc10a11a..94481e2210becb155bcf27fc8bd45f2c7ca8dcf3 100644 --- a/klaus/templates/tree.inc.html +++ b/klaus/templates/tree.inc.html @@ -1,5 +1,7 @@ <div class=tree> - <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, rev=rev) }}">{{ rev|shorten_sha1 }}</a></h2> + <h2>Tree @<a href="{{ url_for('commit', repo=repo.name, rev=rev) }}">{{ rev|shorten_sha1 }}</a> + <span>(<a href="{{ url_for('download', repo=repo.name, rev=rev) }}">Download tar</a>)</span> + </h2> <ul> {% for _, name, fullpath in root_tree.dirs %} <li><a href="{{ url_for('history', repo=repo.name, rev=rev, path=fullpath) }}" class=dir>{{ name|force_unicode }}</a></li> diff --git a/klaus/views.py b/klaus/views.py index 326e9a8bbd744c71408c0fb944f7322f5916c96a..07c27fe5fdb40843e5985f2459c080a515f3d83b 100644 --- a/klaus/views.py +++ b/klaus/views.py @@ -9,7 +9,7 @@ from werkzeug.exceptions import NotFound from dulwich.objects import Blob -from klaus import markup +from klaus import markup, tarutils from klaus.utils import parent_directory, subpaths, pygmentize, \ force_unicode, guess_is_binary, guess_is_image @@ -233,8 +233,31 @@ class RawView(BlobViewMixin, BaseRepoView): return Response(self.context['blob_or_tree'].chunked, mimetype='') -# TODO v +class DownloadView(BaseRepoView): + """ + Download a repo as a tar file + """ + def get_response(self): + tarname = "%s@%s.tar" % (self.context['repo'].name, self.context['rev']) + headers = { + 'Content-Disposition': "attachment; filename=%s" % tarname, + 'Cache-Control': "no-store", # Disables browser caching + } + + tar_stream = tarutils.tar_stream( + self.context['repo'], + self.context['blob_or_tree'], + self.context['commit'].commit_time + ) + return Response( + tar_stream, + mimetype="application/tar", + headers=headers + ) + + history = HistoryView.as_view('history', 'history', 'history.html') commit = BaseRepoView.as_view('commit', 'commit', 'view_commit.html') blob = BlobView.as_view('blob', 'blob', 'view_blob.html') raw = RawView.as_view('raw', 'raw') +download = DownloadView.as_view('download', 'download')