Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature download files #101

Merged
merged 4 commits into from
Aug 25, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions sacredboard/app/data/datastorage.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Interfaces for data storage."""
from sacredboard.app.data.rundao import RunDAO
from .errors import NotFoundError
from .filesdao import FilesDAO
from .metricsdao import MetricsDAO


Expand Down Expand Up @@ -52,6 +53,15 @@ def get_run_dao(self) -> RunDAO:
raise NotImplementedError(
"Run Data Access Object must be implemented.")

def get_files_dao(self) -> FilesDAO:
"""
Return a data access object for files.

:return: FilesDAO
"""
raise NotImplementedError(
"Artifacts Data Access Object must be implemented.")


class DummyMetricsDAO(MetricsDAO):
"""Dummy Metrics DAO that does not find any metric."""
Expand Down
13 changes: 13 additions & 0 deletions sacredboard/app/data/filesdao.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Interface for accessing Sacred files."""


class FilesDAO:
"""Interface for accessing files."""

def get(self, file_id):
"""
Return the file associated with the id.

:raise NotFoundError when not found
"""
raise NotImplementedError("RunDAO is abstract.")
3 changes: 2 additions & 1 deletion sacredboard/app/data/pymongo/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Module responsible for accessing the MongoDB database."""
from .genericdao import GenericDAO
from .metricsdao import MongoMetricsDAO
from .filesdao import MongoFilesDAO
from .mongodb import PyMongoDataAccess

__all__ = ("MongoMetricsDAO", "GenericDAO", "PyMongoDataAccess")
__all__ = ("MongoMetricsDAO", "GenericDAO", "PyMongoDataAccess", "MongoFilesDAO")
35 changes: 35 additions & 0 deletions sacredboard/app/data/pymongo/filesdao.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Module responsible for accessing the Files data in MongoDB."""
from typing import Union

import bson
import gridfs

from sacredboard.app.data.pymongo import GenericDAO
from sacredboard.app.data.filesdao import FilesDAO


class MongoFilesDAO(FilesDAO):
"""Implementation of FilesDAO for MongoDB."""

def __init__(self, generic_dao: GenericDAO):
"""
Create new Files accessor for MongoDB.

:param generic_dao: A configured generic MongoDB data access object
pointing to an appropriate database.
"""
self.generic_dao = generic_dao

self._fs = gridfs.GridFS(self.generic_dao._database)

def get(self, file_id: Union[str, bson.ObjectId]) -> gridfs.GridOut:
"""
Return the file identified by a file_id string.

The return value is a file-like object which also has the following attributes:
filename: str
upload_date: datetime
"""
if isinstance(file_id, str):
file_id = bson.ObjectId(file_id)
return self._fs.get(file_id)
10 changes: 9 additions & 1 deletion sacredboard/app/data/pymongo/mongodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import pymongo

from sacredboard.app.data.datastorage import Cursor, DataStorage
from sacredboard.app.data.pymongo import GenericDAO, MongoMetricsDAO
from sacredboard.app.data.pymongo import GenericDAO, MongoMetricsDAO, MongoFilesDAO
from sacredboard.app.data.pymongo.rundao import MongoRunDAO


Expand Down Expand Up @@ -102,3 +102,11 @@ def get_run_dao(self):
:return: RunDAO
"""
return MongoRunDAO(self._generic_dao, self._collection_name)

def get_files_dao(self):
"""
Return a data access object for Files.

:return: RunDAO
"""
return MongoFilesDAO(self._generic_dao)
127 changes: 127 additions & 0 deletions sacredboard/app/webapi/files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
"""Accessing the files."""
from enum import Enum
import io
import os
import mimetypes
import zipfile

from flask import Blueprint, current_app, render_template, send_file, Response

from sacredboard.app.data import NotFoundError


files = Blueprint("files", __name__)


class _FileType(Enum):
ARTIFACT = 1
SOURCE = 2


_filetype_suffices = {
_FileType.ARTIFACT: "artifact",
_FileType.SOURCE: "source",
}


def _get_binary_info(binary: bytes):
hex_data = ""
for i in range(0, 10):
if i > 0:
hex_data += " "
hex_data += hex(binary[i])
hex_data += " ..."
return "Binary data\nLength: {}\nFirst 10 bytes: {}".format(len(binary), hex_data)


def get_file(file_id: str, download):
"""
Get a specific file from GridFS.

Returns a binary stream response or HTTP 404 if not found.
"""
data = current_app.config["data"] # type: DataStorage
dao = data.get_files_dao()
file = dao.get(file_id)

if download:
mime = mimetypes.guess_type(file.filename)[0]
if mime is None:
# unknown type
mime = "binary/octet-stream"

basename = os.path.basename(file.filename)
return send_file(file, mimetype=mime, attachment_filename=basename, as_attachment=True)
else:
rawdata = file.read()
try:
text = rawdata.decode('utf-8')
except UnicodeDecodeError:
# not decodable as utf-8
text = _get_binary_info(rawdata)
html = render_template("api/file_view.html", content=text)
return Response(html)


def get_files_zip(run_id: int, filetype: _FileType):
"""Send all artifacts or sources of a run as ZIP."""
data = current_app.config["data"]
dao_runs = data.get_run_dao()
dao_files = data.get_files_dao()
run = dao_runs.get(run_id)

if filetype == _FileType.ARTIFACT:
target_files = run['artifacts']
elif filetype == _FileType.SOURCE:
target_files = run['experiment']['sources']
else:
raise Exception("Unknown file type: %s" % filetype)

memory_file = io.BytesIO()
with zipfile.ZipFile(memory_file, 'w') as zf:
for f in target_files:
# source and artifact files use a different data structure
file_id = f['file_id'] if 'file_id' in f else f[1]
file = dao_files.get(file_id)
data = zipfile.ZipInfo(file.filename, date_time=file.upload_date.timetuple())
data.compress_type = zipfile.ZIP_DEFLATED
zf.writestr(data, file.read())
memory_file.seek(0)

fn_suffix = _filetype_suffices[filetype]
return send_file(memory_file, attachment_filename='run{}_{}.zip'.format(run_id, fn_suffix), as_attachment=True)


@files.route("/api/file/<string:file_id>")
def api_file(file_id):
"""Download a file."""
return get_file(file_id, True)


@files.route("/api/fileview/<string:file_id>")
def api_fileview(file_id):
"""View a file."""
return get_file(file_id, False)


@files.route("/api/artifacts/<int:run_id>")
def api_artifacts(run_id):
"""Download all artifacts of a run as ZIP."""
return get_files_zip(run_id, _FileType.ARTIFACT)


@files.route("/api/sources/<int:run_id>")
def api_sources(run_id):
"""Download all sources of a run as ZIP."""
return get_files_zip(run_id, _FileType.SOURCE)


@files.errorhandler(NotFoundError)
def handle_not_found_error(e):
"""Handle exception when a metric is not found."""
return "Couldn't find resource:\n%s" % e, 404


def initialize(app, app_config):
"""Register the module in Flask."""
app.register_blueprint(files)
4 changes: 2 additions & 2 deletions sacredboard/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@
from sacredboard.app.config import jinja_filters
from sacredboard.app.data.filestorage import FileStorage
from sacredboard.app.data.pymongo import PyMongoDataAccess
from sacredboard.app.webapi import routes, metrics, runs, proxy
from sacredboard.app.webapi import routes, metrics, runs, files, proxy
from sacredboard.app.webapi.wsgi_server import ServerRunner

locale.setlocale(locale.LC_ALL, '')
app = Flask(__name__)
server_runner = ServerRunner()
webapi_modules = [proxy, routes, metrics, runs, jinja_filters, server_runner]
webapi_modules = [proxy, routes, metrics, runs, files, jinja_filters, server_runner]


@click.command()
Expand Down
3 changes: 2 additions & 1 deletion sacredboard/static/scripts/runs/detailView/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ define(
"text!./template.html",
"runs/dictionaryBrowser/component",
"runs/metricsViewer/component",
"runs/filesBrowser/component",
"jquery"],
function (ko, escapeHtml, htmlTemplate, dictionaryBrowser, metricsViewer, $) {
function (ko, escapeHtml, htmlTemplate, dictionaryBrowser, metricsViewer, filesBrowser, $) {
ko.components.register("detail-view", {
/**
* Remove run.
Expand Down
24 changes: 24 additions & 0 deletions sacredboard/static/scripts/runs/detailView/template.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ <h3>Details for: <!--ko text: run.experiment_name--><!--/ko-->
<a data-bind="attr: {href: '#metrics-' + run.id}"
data-toggle="pill">Metrics plots</a>
</li>
<li role="presentation">
<a data-bind="attr: {href: '#artifacts-' + run.id}"
data-toggle="pill">Artifacts</a>
</li>
<li role="presentation">
<a data-bind="attr: {href: '#sources-' + run.id}"
data-toggle="pill">Sources</a>
</li>
<li>
<button class="btn btn-block btn-danger"
data-bind="click: deleteRun">
Expand Down Expand Up @@ -146,6 +154,22 @@ <h4>Tensorflow logs</h4>
<metrics-viewer params="run: run.object"></metrics-viewer>
</div>
</div>
<div data-bind="attr: {id: 'artifacts-' + run.id}"
class="tab-pane table-responsive">
<h4>Artifacts</h4>
<div class="detail-page-box">
<files-browser
params='files: run.object.artifacts, run_id:run.object._id, all_url:"/api/artifacts/"'></files-browser>
</div>
</div>
<div data-bind="attr: {id: 'sources-' + run.id}"
class="tab-pane table-responsive">
<h4>Sources</h4>
<div class="detail-page-box">
<files-browser
params='files: run.object.experiment.sources, run_id:run.object._id, all_url:"/api/sources/"'></files-browser>
</div>
</div>
</div>
</div>
</div>
37 changes: 37 additions & 0 deletions sacredboard/static/scripts/runs/filesBrowser/component.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"use strict";
define(["knockout", "text!runs/filesBrowser/template.html"],
function (ko, htmlTemplate) {
ko.components.register("files-browser", {
viewModel: function (params) {
var self = this;
this.run_id = params.run_id;
this.files = params.files;
this.url_all = params.all_url;
this.files = [];

// sources files just are arrays. convert to objects here
params.files.forEach((element, index, array) => {
if (Array.isArray(element)) {
this.files.push({name: element[0], file_id: element[1]});
}
else {
// just pass through
this.files.push(element);
}
});

this.downloadFile = function (file) {
window.location.href = `api/file/${file.file_id.$oid}`;
};

this.viewFile = function (file) {
window.open(`api/fileview/${file.file_id.$oid}`, "_blank");
};

this.downloadFileAll = function (vm) {
window.location.href = this.url_all + vm.run_id;
};
},
template: htmlTemplate
});
});
25 changes: 25 additions & 0 deletions sacredboard/static/scripts/runs/filesBrowser/template.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!-- ko if: files.length > 0 -->
<table class="table table-condensed table-hover"
style="border-left: groove">
<tbody as data-bind="foreach: files">
<tr>
<td data-bind="text: name"></td>
<td>
<button class="btn btn-info" data-bind="click: $parent.viewFile">
VIEW
</button>
<button class="btn btn-primary" data-bind="click: $parent.downloadFile">
DOWNLOAD
</button>
</td>
</tr>
</tbody>
</table>
<button class="btn btn-primary" data-bind="click: downloadFileAll">
DOWNLOAD ALL
</button>
<!-- /ko -->
<!-- ko ifnot: files.length > 0 -->
No files
<!-- /ko -->

6 changes: 6 additions & 0 deletions sacredboard/templates/api/file_view.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<style type="text/css">
div {
white-space: pre-wrap;
}
</style>
<div>{{ content }}</div>
16 changes: 16 additions & 0 deletions sacredboard/templates/api/runs.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@
"heartbeat": {{run.heartbeat | default | format_datetime | tojson}},
"heartbeat_diff": {{run.heartbeat | default | timediff | tojson}},
"hostname": {{run.host.hostname | default | tojson}},
"artifacts": [
{%- for artifact in run.artifacts -%}
{"filename": {{artifact.name | default | tojson}}, "file_id" : {{artifact.file_id | default | string | tojson}}}
{%- if not loop.last -%}
,
{% endif %}
{% endfor %}
],
"sources": [
{%- for source in run.experiment.sources -%}
{"filename": {{source[0] | default | tojson}}, "file_id" : {{source[1] | default | string | tojson}}}
{%- if not loop.last -%}
,
{% endif %}
{% endfor %}
],
{# commented out: "captured_out_last_line": {{run.captured_out | default | last_line | tojson}}, #}
"result":{{run.result | default | dump_json }}
{%- if full_object -%},
Expand Down