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: improved error reporting and new log information support for the Python query runner #479

Merged
merged 1 commit into from
Jul 12, 2015
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
14 changes: 10 additions & 4 deletions rd_ui/app/scripts/controllers/query_view.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
maxAge = -1;
}

$scope.showLog = false;
$scope.queryResult = $scope.query.getQueryResult(maxAge, parameters);
}

Expand Down Expand Up @@ -57,6 +58,7 @@
// in view mode, latest dataset is always visible
// source mode changes this behavior
$scope.showDataset = true;
$scope.showLog = false;

$scope.lockButton = function(lock) {
$scope.queryExecuting = lock;
Expand Down Expand Up @@ -110,21 +112,21 @@
$scope.queryResult.cancelExecution();
Events.record(currentUser, 'cancel_execute', 'query', $scope.query.id);
};

$scope.archiveQuery = function(options, data) {
if (data) {
data.id = $scope.query.id;
} else {
data = $scope.query;
}

$scope.isDirty = false;

options = _.extend({}, {
successMessage: 'Query archived',
errorMessage: 'Query could not be archived'
}, options);

return Query.delete({id: data.id}, function() {
$scope.query.is_archived = true;
$scope.query.schedule = null;
Expand Down Expand Up @@ -197,6 +199,10 @@
if (status === 'done' || status === 'failed') {
$scope.lockButton(false);
}

if ($scope.queryResult.getLog() != null) {
$scope.showLog = true;
}
});

$scope.openScheduleForm = function() {
Expand Down
8 changes: 8 additions & 0 deletions rd_ui/app/scripts/services/resources.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,14 @@
return this.job.error;
}

QueryResult.prototype.getLog = function() {
if (!this.query_result.data || !this.query_result.data.log || this.query_result.data.log.length == 0) {
return null;
}

return this.query_result.data.log;
}

QueryResult.prototype.getUpdatedAt = function () {
return this.query_result.retrieved_at || this.job.updated_at * 1000.0 || this.updatedAt;
}
Expand Down
4 changes: 4 additions & 0 deletions rd_ui/app/styles/redash.css
Original file line number Diff line number Diff line change
Expand Up @@ -339,3 +339,7 @@ use this class when you need to keep the original display value
display: none !important;
}
}

.log-container {
margin-bottom: 50px;
}
10 changes: 10 additions & 0 deletions rd_ui/app/views/query.html
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ <h4 class="modal-title">Query Archive</h4>
</div>
<div class="alert alert-danger" ng-show="queryResult.getError()">Error running query: <strong>{{queryResult.getError()}}</strong></div>

<div class="row log-container" ng-show="showLog">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it take space (due to the margin-bottom style) when ng-show=false?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I checked and it basically hides without taking any height, so the margin doesn't affect anything.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

<span ng-show="showLog">Log Information:</span>
<table>
<tbody>
<tr ng-repeat="l in queryResult.getLog()">
<td>{{l}}</td>
</tr>
</tbody>
</table>
</div>
<!-- tabs and data -->
<div ng-show="showDataset">
<div class="row">
Expand Down
218 changes: 118 additions & 100 deletions redash/query_runner/python.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import sys
import datetime
import json
import logging
import weakref

from redash.query_runner import *
from redash import models
Expand All @@ -12,96 +14,23 @@
from RestrictedPython import compile_restricted
from RestrictedPython.Guards import safe_builtins

ALLOWED_MODULES = {}
class CustomPrint(object):
""" CustomPrint redirect "print" calls to be sent as "log" on the result object """
def __init__(self, python_runner):
self._python_runner = python_runner

def write(self, text):
if self._python_runner()._enable_print_log:
if text and text.strip():
log_line = "[{0}] {1}".format(datetime.datetime.utcnow().isoformat(), text)
self._python_runner()._result["log"].append(log_line)

def custom_write(obj):
"""
Custom hooks which controls the way objects/lists/tuples/dicts behave in
RestrictedPython
"""
return obj


def custom_import(name, globals=None, locals=None, fromlist=(), level=0):
if name in ALLOWED_MODULES:
m = None
if ALLOWED_MODULES[name] is None:
m = importlib.import_module(name)
ALLOWED_MODULES[name] = m
else:
m = ALLOWED_MODULES[name]

return m

raise Exception("'{0}' is not configured as a supported import module".format(name))

def custom_get_item(obj, key):
return obj[key]

def custom_get_iter(obj):
return iter(obj)

def get_query_result(query_id):
try:
query = models.Query.get_by_id(query_id)
except models.Query.DoesNotExist:
raise Exception("Query id %s does not exist." % query_id)

if query.latest_query_data is None:
raise Exception("Query does not have results yet.")

if query.latest_query_data.data is None:
raise Exception("Query does not have results yet.")

return json.loads(query.latest_query_data.data)


def execute_query(data_source_name_or_id, query):
try:
if type(data_source_name_or_id) == int:
data_source = models.DataSource.get_by_id(data_source_name_or_id)
else:
data_source = models.DataSource.get(models.DataSource.name==data_source_name_or_id)
except models.DataSource.DoesNotExist:
raise Exception("Wrong data source name/id: %s." % data_source_name_or_id)

query_runner = get_query_runner(data_source.type, data_source.options)

data, error = query_runner.run_query(query)
if error is not None:
raise Exception(error)

# TODO: allow avoiding the json.dumps/loads in same process
return json.loads(data)


def add_result_column(result, column_name, friendly_name, column_type):
""" Helper function to add columns inside a Python script running in re:dash in an easier way """
if column_type not in SUPPORTED_COLUMN_TYPES:
raise Exception("'{0}' is not a supported column type".format(column_type))

if not "columns" in result:
result["columns"] = []

result["columns"].append({
"name" : column_name,
"friendly_name" : friendly_name,
"type" : column_type
})


def add_result_row(result, values):
if not "rows" in result:
result["rows"] = []

result["rows"].append(values)
def __call__(self):
return self


class Python(BaseQueryRunner):
"""
This is very, very unsafe. Use at your own risk with people you really trust.
"""

@classmethod
def configuration_schema(cls):
return {
Expand Down Expand Up @@ -129,32 +58,123 @@ def __init__(self, configuration_json):

self.syntax = "python"

self._allowed_modules = {}
self._result = { "rows" : [], "columns" : [], "log" : [] }
self._enable_print_log = True

if self.configuration.get("allowedImportModules", None):
for item in self.configuration["allowedImportModules"].split(","):
ALLOWED_MODULES[item] = None
self._allowed_modules[item] = None

def custom_import(self, name, globals=None, locals=None, fromlist=(), level=0):
if name in self._allowed_modules:
m = None
if self._allowed_modules[name] is None:
m = importlib.import_module(name)
self._allowed_modules[name] = m
else:
m = self._allowed_modules[name]

return m

raise Exception("'{0}' is not configured as a supported import module".format(name))

def custom_write(self, obj):
"""
Custom hooks which controls the way objects/lists/tuples/dicts behave in
RestrictedPython
"""
return obj

def custom_get_item(self, obj, key):
return obj[key]

def custom_get_iter(self, obj):
return iter(obj)

def disable_print_log(self):
self._enable_print_log = False

def enable_print_log(self):
self._enable_print_log = True

def add_result_column(self, result, column_name, friendly_name, column_type):
""" Helper function to add columns inside a Python script running in re:dash in an easier way """
if column_type not in SUPPORTED_COLUMN_TYPES:
raise Exception("'{0}' is not a supported column type".format(column_type))

if not "columns" in result:
result["columns"] = []

result["columns"].append({
"name" : column_name,
"friendly_name" : friendly_name,
"type" : column_type
})

def add_result_row(self, result, values):
if not "rows" in result:
result["rows"] = []

result["rows"].append(values)

def execute_query(self, data_source_name_or_id, query):
try:
if type(data_source_name_or_id) == int:
data_source = models.DataSource.get_by_id(data_source_name_or_id)
else:
data_source = models.DataSource.get(models.DataSource.name==data_source_name_or_id)
except models.DataSource.DoesNotExist:
raise Exception("Wrong data source name/id: %s." % data_source_name_or_id)

query_runner = get_query_runner(data_source.type, data_source.options)

data, error = query_runner.run_query(query)
if error is not None:
raise Exception(error)

# TODO: allow avoiding the json.dumps/loads in same process
return json.loads(data)

def get_query_result(self, query_id):
try:
query = models.Query.get_by_id(query_id)
except models.Query.DoesNotExist:
raise Exception("Query id %s does not exist." % query_id)

if query.latest_query_data is None:
raise Exception("Query does not have results yet.")

if query.latest_query_data.data is None:
raise Exception("Query does not have results yet.")

return json.loads(query.latest_query_data.data)

def run_query(self, query):
try:
error = None

code = compile_restricted(query, '<string>', 'exec')

safe_builtins["_write_"] = custom_write
safe_builtins["__import__"] = custom_import
safe_builtins["_write_"] = self.custom_write
safe_builtins["__import__"] = self.custom_import
safe_builtins["_getattr_"] = getattr
safe_builtins["getattr"] = getattr
safe_builtins["_setattr_"] = setattr
safe_builtins["setattr"] = setattr
safe_builtins["_getitem_"] = custom_get_item
safe_builtins["_getiter_"] = custom_get_iter
safe_builtins["_getitem_"] = self.custom_get_item
safe_builtins["_getiter_"] = self.custom_get_iter
safe_builtins["_print_"] = CustomPrint(weakref.ref(self))

script_locals = { "result" : { "rows" : [], "columns" : [] } }
script_locals = { "result" : self._result }

restricted_globals = dict(__builtins__=safe_builtins)
restricted_globals["get_query_result"] = get_query_result
restricted_globals["execute_query"] = execute_query
restricted_globals["add_result_column"] = add_result_column
restricted_globals["add_result_row"] = add_result_row
restricted_globals["get_query_result"] = self.get_query_result
restricted_globals["execute_query"] = self.execute_query
restricted_globals["add_result_column"] = self.add_result_column
restricted_globals["add_result_row"] = self.add_result_row
restricted_globals["disable_print_log"] = self.disable_print_log
restricted_globals["enable_print_log"] = self.enable_print_log

restricted_globals["TYPE_DATETIME"] = TYPE_DATETIME
restricted_globals["TYPE_BOOLEAN"] = TYPE_BOOLEAN
Expand All @@ -169,15 +189,13 @@ def run_query(self, query):

exec(code) in restricted_globals, script_locals

if script_locals['result'] is None:
raise Exception("result wasn't set to value.")

json_data = json.dumps(script_locals['result'])
json_data = json.dumps(self._result)
except KeyboardInterrupt:
error = "Query cancelled by user."
json_data = None
except Exception as e:
raise sys.exc_info()[1], None, sys.exc_info()[2]
error = str(e)
json_data = None

return json_data, error

Expand Down