diff --git a/.gitignore b/.gitignore
index b9427dff..af4940cf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,4 @@
/tmp
/vagrant/
*.sublime-*
+/.idea
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index 5ad9cd1d..3b1b2cd2 100644
--- a/Gemfile
+++ b/Gemfile
@@ -33,6 +33,8 @@ gem 'thread_safe'
gem 'turbolinks'
gem 'uglifier', '>= 1.3.0'
gem 'will_paginate', '~> 3.0'
+gem 'tubesock'
+gem 'faye-websocket'
group :development do
gem 'better_errors', platform: :ruby
diff --git a/Gemfile.lock b/Gemfile.lock
index 6253088e..812bdaf2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -50,8 +50,6 @@ GEM
bootstrap-will_paginate (0.0.10)
will_paginate
builder (3.2.2)
- byebug (4.0.5)
- columnize (= 0.9.0)
capistrano (3.3.5)
capistrano-stats (~> 1.1.0)
i18n
@@ -96,7 +94,6 @@ GEM
execjs
coffee-script-source (1.9.1)
colorize (0.7.7)
- columnize (0.9.0)
concurrent-ruby (0.8.0)
ref (~> 1.0, >= 1.0.5)
concurrent-ruby (0.8.0-java)
@@ -110,6 +107,7 @@ GEM
excon (>= 0.38.0)
json
erubis (2.7.0)
+ eventmachine (1.0.8)
excon (0.45.2)
execjs (2.5.2)
factory_girl (4.5.0)
@@ -119,6 +117,9 @@ GEM
railties (>= 3.0.0)
faraday (0.9.1)
multipart-post (>= 1.2, < 3)
+ faye-websocket (0.10.0)
+ eventmachine (>= 0.12.0)
+ websocket-driver (>= 0.5.1)
ffi (1.9.8)
ffi (1.9.8-java)
forgery (0.6.0)
@@ -302,6 +303,9 @@ GEM
thread_safe (0.3.5)
thread_safe (0.3.5-java)
tilt (1.4.1)
+ tubesock (0.2.5)
+ rack (>= 1.5.0)
+ websocket (>= 1.1.0)
turbolinks (2.5.3)
coffee-rails
tzinfo (1.2.2)
@@ -315,6 +319,9 @@ GEM
railties (>= 4.0)
sprockets-rails (>= 2.0, < 4.0)
websocket (1.2.1)
+ websocket-driver (0.6.2)
+ websocket-extensions (>= 0.1.0)
+ websocket-extensions (0.1.2)
will_paginate (3.0.7)
xpath (2.0.0)
nokogiri (~> 1.3)
@@ -330,7 +337,6 @@ DEPENDENCIES
better_errors
binding_of_caller
bootstrap-will_paginate
- byebug
capistrano (~> 3.3.0)
capistrano-rails
capistrano-rvm
@@ -345,6 +351,7 @@ DEPENDENCIES
database_cleaner
docker-api (~> 1.21.1)
factory_girl_rails (~> 4.0)
+ faye-websocket
forgery
highline
ims-lti
@@ -375,6 +382,7 @@ DEPENDENCIES
sorcery
spring
thread_safe
+ tubesock
turbolinks
uglifier (>= 1.3.0)
web-console (~> 2.0)
diff --git a/app/assets/javascripts/editor.js b/app/assets/javascripts/editor.js
index e2598eb7..160cf315 100644
--- a/app/assets/javascripts/editor.js
+++ b/app/assets/javascripts/editor.js
@@ -11,6 +11,7 @@ $(function() {
var FILENAME_URL_PLACEHOLDER = '{filename}';
var SUCCESSFULL_PERCENTAGE = 90;
var THEME = 'ace/theme/textmate';
+ var REMEMBER_TAB = false;
var AUTOSAVE_INTERVAL = 15 * 1000;
var editors = [];
@@ -20,6 +21,16 @@ $(function() {
var qa_api = undefined;
var output_mode_is_streaming = true;
+ var websocket,
+ turtlescreen,
+ numMessages = 0,
+ turtlecanvas = $('#turtlecanvas'),
+ prompt = $('#prompt'),
+ commands = ['input', 'write', 'turtle', 'turtlebatch', 'exit', 'status'],
+ streams = ['stdin', 'stdout', 'stderr'];
+
+ var ENTER_KEY_CODE = 13;
+
var flowrResultHtml = '
'
var ajax = function(options) {
@@ -49,7 +60,7 @@ $(function() {
if (event.type === 'error' || JSON.parse(event.data).code !== 200) {
ajaxError();
- showTab(1);
+ showTab(0);
}
};
@@ -181,14 +192,15 @@ $(function() {
};
var evaluateCodeWithStreamedResponse = function(url, callback) {
- var event_source = new EventSource(url);
+ initWebsocketConnection(url);
- event_source.addEventListener('close', closeEventSource);
- event_source.addEventListener('error', closeEventSource);
+ // TODO only init turtle when required
+ initTurtle();
+
+ // TODO reimplement via websocket messsages
+ /*var event_source = new EventSource(url);
event_source.addEventListener('hint', renderHint);
event_source.addEventListener('info', storeContainerInformation);
- event_source.addEventListener('output', callback);
- event_source.addEventListener('start', callback);
if ($('#flowrHint').isPresent()) {
event_source.addEventListener('output', handleStderrOutputForFlowr);
@@ -197,11 +209,7 @@ $(function() {
if (qa_api) {
event_source.addEventListener('close', handleStreamedResponseForCodePilot);
- }
-
- event_source.addEventListener('status', function(event) {
- showStatus(JSON.parse(event.data));
- });
+ }*/
};
var handleStreamedResponseForCodePilot = function(event) {
@@ -255,13 +263,11 @@ $(function() {
var handleKeyPress = function(event) {
if (event.which === ALT_1_KEY_CODE) {
- showTab(0);
- } else if (event.which === ALT_2_KEY_CODE) {
showWorkspaceTab(event);
+ } else if (event.which === ALT_2_KEY_CODE) {
+ showTab(1);
} else if (event.which === ALT_3_KEY_CODE) {
showTab(2);
- } else if (event.which === ALT_4_KEY_CODE) {
- showTab(3);
} else if (event.which === ALT_R_KEY_CODE) {
$('#run').trigger('click');
} else if (event.which === ALT_S_KEY_CODE) {
@@ -304,7 +310,7 @@ $(function() {
}, 0).toFixed(2);
$('#score').data('score', score);
renderScore();
- showTab(3);
+ showTab(2);
};
var stderrOutput = '';
@@ -357,7 +363,7 @@ $(function() {
qa_api.executeCommand('syncOutput', [response]);
}
showStatus(response[0]);
- showTab(2);
+ showTab(1);
};
var hideSpinner = function() {
@@ -428,7 +434,7 @@ $(function() {
handleSidebarClick(e);
});
*/
-
+
//session
session.on('annotationRemoval', handleAnnotationRemoval);
session.on('annotationChange', handleAnnotationChange);
@@ -631,11 +637,11 @@ $(function() {
};
var initializeWorkspaceButtons = function() {
- $('#assess').on('click', scoreCode);
+ $('#assess').on('click', scoreCode); // todo
$('#dropdown-render, #render').on('click', renderCode);
$('#dropdown-run, #run').on('click', runCode);
- $('#dropdown-stop, #stop').on('click', stopCode);
- $('#dropdown-test, #test').on('click', testCode);
+ $('#dropdown-stop, #stop').on('click', stopCode); // todo
+ $('#dropdown-test, #test').on('click', testCode); // todo
$('#save').on('click', saveCode);
$('#start-over').on('click', confirmReset);
};
@@ -676,6 +682,7 @@ $(function() {
};
var isBrowserSupported = function() {
+ // todo event streams are no longer required with websockets
return window.EventSource !== undefined;
};
@@ -703,12 +710,16 @@ $(function() {
chunkBuffer.push(output);
}
} else {
+ resetOutputTab();
+ }
+ };
+
+ var resetOutputTab = function() {
clearOutput();
$('#hint').fadeOut();
$('#flowrHint').fadeOut();
- showTab(2);
- }
- };
+ showTab(1);
+ }
var printOutput = function(output, colorize, index) {
var element = findOrCreateOutputElement(index);
@@ -808,7 +819,7 @@ $(function() {
stderr: message
}, true, 0);
sendError(message, response.id);
- showTab(2);
+ showTab(1);
};
}
});
@@ -931,16 +942,21 @@ $(function() {
var showOutput = function(event) {
event.preventDefault();
- showTab(2);
+ showTab(1);
$('#output').scrollTo($(this).attr('href'));
};
var showRequestedTab = function() {
- var regexp = /tab=(\d+)/;
- if (regexp.test(window.location.search)) {
- var index = regexp.exec(window.location.search)[1] - 1;
+ if(REMEMBER_TAB){
+ var regexp = /tab=(\d+)/;
+ if (regexp.test(window.location.search)) {
+ var index = regexp.exec(window.location.search)[1] - 1;
+ } else {
+ var index = localStorage.tab;
+ }
} else {
- var index = localStorage.tab;
+ // else start with first tab.
+ var index = 0;
}
showTab(index);
};
@@ -954,7 +970,7 @@ $(function() {
if (output.status === 'timeout') {
showTimeoutMessage();
} else if (output.status === 'container_depleted') {
- showContainerDepletedMessage();
+ showContainerDepletedMessage();
} else if (output.stderr) {
$.flash.danger({
icon: ['fa', 'fa-bug'],
@@ -988,29 +1004,46 @@ $(function() {
});
};
+ var showWebsocketError = function() {
+ $.flash.danger({
+ text: $('#flash').data('message-failure')
+ });
+ }
+
var showWorkspaceTab = function(event) {
event.preventDefault();
- showTab(1);
+ showTab(0);
};
var stopCode = function(event) {
event.preventDefault();
if ($('#stop').is(':visible')) {
- var jqxhr = ajax({
- data: {
- container_id: $('#stop').data('container').id
- },
- url: $('#stop').data('url')
- });
- jqxhr.always(function() {
- hideSpinner();
- running = false;
- toggleButtonStates();
- });
- jqxhr.fail(ajaxError);
+ killWebsocketAndContainer();
}
};
+ var killWebsocketAndContainer = function() {
+ if (websocket.readyState != WebSocket.OPEN) {
+ return;
+ }
+ websocket.send(JSON.stringify({cmd: 'exit'}));
+ websocket.flush();
+ websocket.close();
+ hideSpinner();
+ running = false;
+ toggleButtonStates();
+ hidePrompt();
+ flashKillMessage();
+ }
+
+ var flashKillMessage = function() {
+ $.flash.info({
+ icon: ['fa', 'fa-clock-o'],
+ text: "Your program was stopped." // todo get data attribute
+ });
+ }
+
+ // todo set this from websocket command, required to e.g. stop container
var storeContainerInformation = function(event) {
var container_information = JSON.parse(event.data);
$('#stop').data('container', container_information);
@@ -1065,6 +1098,164 @@ $(function() {
$('#request-for-comments').toggle(isActiveFileSubmission() && !isActiveFileBinary());
};
+ var initWebsocketConnection = function(url) {
+ websocket = new WebSocket('ws://' + window.location.hostname + ':' + window.location.port + url);
+ websocket.onopen = function(evt) { resetOutputTab(); }; // todo show some kind of indicator for established connection
+ websocket.onclose = function(evt) { /* expected at some point */ };
+ websocket.onmessage = function(evt) { parseCanvasMessage(evt.data, true); };
+ websocket.onerror = function(evt) { showWebsocketError(); };
+ websocket.flush = function() { this.send('\n'); }
+ };
+
+ var initTurtle = function() {
+ // todo guard clause if turtle is not required for the current exercise
+ turtlescreen = new Turtle(websocket, turtlecanvas);
+ if ($('#run').isPresent()) {
+ $('#run').bind('click', hideCanvas);
+ }
+ };
+
+ var initPrompt = function() {
+ if ($('#run').isPresent()) {
+ $('#run').bind('click', hidePrompt);
+ }
+ if ($('#prompt').isPresent()) {
+ $('#prompt').on('keypress', handlePromptKeyPress);
+ $('#prompt-submit').on('click', submitPromptInput);
+ }
+ }
+
+ var executeWebsocketCommand = function(msg) {
+ if ($.inArray(msg.cmd, commands) == -1) {
+ console.log("Unknown command: " + msg.cmd);
+ // skipping unregistered commands is required
+ // as we may receive mirrored response due to internal behaviour
+ return;
+ }
+ switch(msg.cmd) {
+ case 'input':
+ showPrompt();
+ break;
+ case 'write':
+ printWebsocketOutput(msg);
+ break;
+ case 'turtle':
+ showCanvas();
+ handleTurtleCommand(msg);
+ break;
+ case 'turtlebatch':
+ showCanvas();
+ handleTurtlebatchCommand(msg);
+ break;
+ case 'exit':
+ killWebsocketAndContainer();
+ break;
+ case 'status':
+ showStatus(msg)
+ break;
+ }
+ };
+
+ var printWebsocketOutput = function(msg) {
+ if (!msg.data) {
+ return;
+ }
+ //msg.data = msg.data.replace(/(\r\n|\n|\r)/gm, "
");
+ msg.data = msg.data.replace(/(\r)/gm, "\n");
+ var stream = {};
+ stream[msg.stream] = msg.data;
+ printOutput(stream, true, 0);
+ };
+
+ var handleTurtleCommand = function(msg) {
+ if (msg.action in turtlescreen) {
+ result = turtlescreen[msg.action].apply(turtlescreen, msg.args);
+ websocket.send(JSON.stringify({cmd: 'result', 'result': result}));
+ } else {
+ websocket.send(JSON.stringify({cmd: 'exception', exception: 'AttributeError', message: msg.action}));
+ }
+ websocket.flush();
+ };
+
+ var handleTurtlebatchCommand = function(msg) {
+ for (i = 0; i < msg.batch.length; i++) {
+ cmd = msg.batch[i];
+ turtlescreen[cmd[0]].apply(turtlescreen, cmd[1]);
+ }
+ };
+
+ var handlePromptKeyPress = function(evt) {
+ if (evt.which === ENTER_KEY_CODE) {
+ submitPromptInput();
+ }
+ }
+
+ var submitPromptInput = function() {
+ var input = $('#prompt-input');
+ var message = input.val();
+ websocket.send(JSON.stringify({cmd: 'result', 'data': message}));
+ websocket.flush();
+ input.val('');
+ hidePrompt();
+ }
+
+ var parseCanvasMessage = function(message, recursive) {
+ var msg;
+ message = message.replace(/^\s+|\s+$/g, "");
+ try {
+ // todo validate json instead of catching
+ msg = JSON.parse(message);
+ } catch (e) {
+ if (!recursive) {
+ return false;
+ }
+ // why does docker sometimes send multiple commands at once?
+ message = message.replace(/^\s+|\s+$/g, "");
+ messages = message.split("\n");
+ for (var i = 0; i < messages.length; i++) {
+ if (!messages[i]) {
+ continue;
+ }
+ parseCanvasMessage(messages[i], false);
+ }
+ return;
+ }
+ executeWebsocketCommand(msg);
+ };
+
+ var showPrompt = function() {
+ if (prompt.isPresent() && prompt.hasClass('hidden')) {
+ prompt.removeClass('hidden');
+ }
+ prompt.focus();
+ }
+
+ var hidePrompt = function() {
+ if (prompt.isPresent() && !prompt.hasClass('hidden')) {
+ prompt.addClass('hidden');
+ }
+ }
+
+ var showCanvas = function() {
+ if ($('#turtlediv').isPresent()
+ && turtlecanvas.hasClass('hidden')) {
+ // initialize two-column layout
+ $('#output-col1').addClass('col-lg-7 col-md-7 two-column');
+ turtlecanvas.removeClass('hidden');
+ }
+ };
+
+ var hideCanvas = function() {
+ if ($('#turtlediv').isPresent()
+ && !(turtlecanvas.hasClass('hidden'))) {
+ output = $('#output-col1');
+ if (output.hasClass('two-column')) {
+ output.removeClass('col-lg-7 col-md-7 two-column');
+ }
+ turtlecanvas.addClass('hidden');
+ }
+ };
+
var requestComments = function(e) {
var user_id = $('#editor').data('user-id')
var exercise_id = $('#editor').data('exercise-id')
@@ -1123,6 +1314,7 @@ $(function() {
initializeEventHandlers();
initializeFileTree();
initializeTooltips();
+ initPrompt();
renderScore();
showFirstFile();
showRequestedTab();
diff --git a/app/assets/javascripts/turtle.js b/app/assets/javascripts/turtle.js
new file mode 100644
index 00000000..8aa8a9fe
--- /dev/null
+++ b/app/assets/javascripts/turtle.js
@@ -0,0 +1,222 @@
+var output;
+var editor;
+var pipeurl;
+var filename;
+var pendingChanges = -1;
+
+function Turtle(pipe, canvas) {
+ var dx, dy, xpos, ypos;
+ this.canvas = canvas; // jQuery object
+ this.items = [];
+ this.canvas.off('click');
+ this.canvas.click(function (e) {
+ if (e.eventPhase !== 2) {
+ return;
+ }
+ e.stopPropagation();
+ dx = this.width / 2;
+ dy = this.height / 2;
+ if(e.offsetX==undefined)
+ {
+ var offset = canvas.offset();
+ xpos = e.pageX-offset.left;
+ ypos = e.pageY-offset.top;
+ }
+ else
+ {
+ xpos = e.offsetX;
+ ypos = e.offsetY;
+ }
+ pipe.send(JSON.stringify({
+ 'cmd': 'canvasevent',
+ 'type': '',
+ 'x': xpos - dx,
+ 'y': ypos - dy
+ }));
+ });
+}
+
+Turtle.prototype.update = function () {
+ var i, k, canvas, ctx, dx, dy, item, c, length;
+ canvas = this.canvas[0];
+ ctx = canvas.getContext('2d');
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ length = this.items.length;
+ dx = canvas.width / 2;
+ dy = canvas.height / 2;
+ for (i = 0; i < length; i += 1) {
+ item = this.items[i];
+ c = item.coords;
+ switch (item.type) {
+ case 'line':
+ ctx.beginPath();
+ ctx.moveTo(c[0] + dx, c[1] + dy);
+ for (k = 2; k < c.length; k += 2) {
+ ctx.lineTo(c[k] + dx, c[k + 1] + dy);
+ }
+ if (this.fill) {
+ ctx.strokeStyle = this.fill;
+ }
+
+ ctx.stroke();
+ break;
+ case 'polygon':
+ ctx.beginPath();
+ ctx.moveTo(c[0] + dx, c[1] + dy);
+ for (k = 2; k < c.length; k += 2) {
+ ctx.lineTo(c[k] + dx, c[k + 1] + dy);
+ }
+ ctx.closePath();
+ if (item.fill !== "") {
+ ctx.fillStyle = item.fill;
+ ctx.strokeStyle = item.fill;
+ ctx.fill();
+ }
+ ctx.stroke();
+ break;
+ case 'image':
+ break;
+ }
+ }
+}
+
+Turtle.prototype.get_width = function () {
+ return this.canvas[0].width;
+}
+
+Turtle.prototype.get_height = function () {
+ return this.canvas[0].height;
+}
+
+Turtle.prototype.delete = function (item) {
+ if (item == 'all') {
+ this.items = [];
+ } else {
+ delete this.items[item];
+ }
+}
+
+Turtle.prototype.create_image = function (image) {
+ this.items.push({type:'image',image:image});
+ return this.items.length - 1;
+}
+
+Turtle.prototype.create_line = function () {
+ this.items.push({type:'line',
+ fill: '',
+ coords:[0,0,0,0],
+ width:2,
+ capstyle:'round'});
+ return this.items.length - 1;
+}
+
+Turtle.prototype.create_polygon = function () {
+ this.items.push({type:'polygon',
+ // fill: "" XXX
+ // outline: "" XXX
+ coords:[0,0,0,0,0,0]
+ });
+ return this.items.length - 1;
+}
+
+// XXX might make this varargs as in Tkinter
+Turtle.prototype.coords = function (item, coords) {
+ if (coords === undefined) {
+ return this.items[item].coords;
+ }
+ this.items[item].coords = coords;
+}
+
+Turtle.prototype.itemconfigure = function (item, key, value) {
+ this.items[item][key] = value;
+}
+
+// value might be undefined
+Turtle.prototype.css = function (key, value) {
+ if (value === undefined) {
+ return this.canvas.css(key);
+ } else {
+ // jQuery return value is confusing when the css is set
+ this.canvas.css(key, value);
+ }
+}
+
+function run(launchmsg) {
+ var i, turtlescreen, msg, result, cmd;
+ $('#assess').empty();
+
+ turtlescreen = new Turtle();
+
+ output = $('#output');
+ output.empty();
+ if (typeof pipeurl === 'undefined') {
+ if (wp_port == '443') {
+ pipeurl = 'wss://'+wp_hostname+'/pipe';
+ } else {
+ pipeurl = 'ws://'+wp_hostname+':'+wp_port+'/pipe';
+ }
+ }
+ saveFile();
+ output.pipe = new WebSocket(pipeurl);
+ output.pipe.onopen = function () {
+ output.pipe.send(JSON.stringify(launchmsg));
+ };
+ output.pipe.onmessage = function (response) {
+ msg = JSON.parse(response.data);
+ if (msg.cmd == 'input') {
+ output.inputelem = $('',{'size':40});
+ submit = $('',{'type':'submit'});
+ submit.click(function (){
+ text = output.inputelem.val();
+ output.input.replaceWith($('', {text:text+'\n'}));
+ output.pipe.send(JSON.stringify({'cmd':'inputresult',
+ 'data':text}));
+ });
+ output.inputelem.keydown(function(event){
+ if(event.keyCode == 13){
+ submit.click();
+ }
+ });
+ output.append($('', {text:msg.data}));
+ output.input = $('').append(output.inputelem).append(submit);
+ output.append(output.input);
+ output.inputelem.focus();
+ } else if (msg.cmd == 'stop') {
+ if (launchmsg.cmd == 'runscript') {
+ if (msg.timedout) {
+ output.append('
Dein Programm hat zu lange gerechnet und wurde beendet.');
+ } else {
+ output.append('
Dein Progamm wurde beendet');
+ }
+ }
+ output.pipe.close();
+ } else if (msg.cmd == 'passed') {
+ $('#assess').html("Herzlich Glückwunsch! Dein Programm funktioniert korrekt.");
+ } else if (msg.cmd == 'failed') {
+ $('#assess').html(msg.data);
+ } else if (msg.cmd == 'turtle') {
+ if (msg.action in turtlescreen) {
+ result = turtlescreen[msg.action].apply(turtlescreen, msg.args);
+ output.pipe.send(JSON.stringify({cmd:'result', 'result':result}));
+ } else {
+ output.pipe.send(JSON.stringify({cmd:'exception', exception:'AttributeError',
+ message:msg.action}));
+ }
+ } else if (msg.cmd == 'turtlebatch') {
+ for (i=0; i < msg.batch.length; i += 1) {
+ cmd = msg.batch[i];
+ turtlescreen[cmd[0]].apply(turtlescreen, cmd[1]);
+ }
+ } else {
+ if(msg.stream == 'internal') {
+ output.append('
Interner Fehler (bitte melden):\n');
+ }
+ else if (msg.stream == 'stderr') {
+ showConsole();
+ $('#consoleradio').prop('checked', 'checked');
+ }
+ output.append($('',{text:msg.data, 'class':msg.stream}));
+ }
+ };
+}
diff --git a/app/controllers/exercises_controller.rb b/app/controllers/exercises_controller.rb
index f2d3390c..7a26627a 100644
--- a/app/controllers/exercises_controller.rb
+++ b/app/controllers/exercises_controller.rb
@@ -62,7 +62,7 @@ class ExercisesController < ApplicationController
end
def exercise_params
- params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name)
+ params[:exercise].permit(:description, :execution_environment_id, :file_id, :instructions, :public, :hide_file_tree, :team_id, :title, files_attributes: file_attributes).merge(user_id: current_user.id, user_type: current_user.class.name)
end
private :exercise_params
diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index b77e4936..a1a804f1 100644
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -4,6 +4,7 @@ class SubmissionsController < ApplicationController
include Lti
include SubmissionParameters
include SubmissionScoring
+ include Tubesock::Hijack
before_action :set_submission, only: [:download_file, :render_file, :run, :score, :show, :statistics, :stop, :test]
before_action :set_docker_client, only: [:run, :test]
@@ -70,20 +71,95 @@ class SubmissionsController < ApplicationController
end
def run
- with_server_sent_events do |server_sent_event|
- output = @docker_client.execute_run_command(@submission, params[:filename])
-
- server_sent_event.write({stdout: output[:stdout]}, event: 'output') if output[:stdout]
- server_sent_event.write({stderr: output[:stderr]}, event: 'output') if output[:stderr]
-
- server_sent_event.write({status: output[:status]}, event: 'status')
-
- unless output[:stderr].nil?
- if hint = Whistleblower.new(execution_environment: @submission.execution_environment).generate_hint(output[:stderr])
- server_sent_event.write(hint, event: 'hint')
- else
- store_error(output[:stderr])
+ # TODO reimplement SSEs with websocket commands
+ # with_server_sent_events do |server_sent_event|
+ # output = @docker_client.execute_run_command(@submission, params[:filename])
+
+ # server_sent_event.write({stdout: output[:stdout]}, event: 'output') if output[:stdout]
+ # server_sent_event.write({stderr: output[:stderr]}, event: 'output') if output[:stderr]
+
+ # unless output[:stderr].nil?
+ # if hint = Whistleblower.new(execution_environment: @submission.execution_environment).generate_hint(output[:stderr])
+ # server_sent_event.write(hint, event: 'hint')
+ # else
+ # store_error(output[:stderr])
+ # end
+ # end
+ # end
+
+ hijack do |tubesock|
+ Thread.new { EventMachine.run } unless EventMachine.reactor_running? && EventMachine.reactor_thread.alive?
+
+ result = @docker_client.execute_run_command(@submission, params[:filename])
+ tubesock.send_data JSON.dump({'cmd' => 'status', 'status' => result[:status]})
+
+ if result[:status] == :container_running
+ socket = result[:socket]
+
+ socket.on :message do |event|
+ Rails.logger.info("Docker sending: " + event.data)
+ handle_message(event.data, tubesock)
end
+
+ socket.on :close do |event|
+ kill_socket(tubesock)
+ end
+
+ tubesock.onmessage do |data|
+ Rails.logger.info("Client sending: " + data)
+ # Check wether the client send a JSON command and kill container
+ # if the command is 'exit', send it to docker otherwise.
+ begin
+ parsed = JSON.parse(data)
+ if parsed['cmd'] == 'exit'
+ Rails.logger.info("Client killed container.")
+ @docker_client.kill_container(result[:container])
+ else
+ socket.send data
+ end
+ rescue JSON::ParserError
+ socket.send data
+ end
+ end
+ else
+ kill_socket(tubesock)
+ end
+ end
+ end
+
+ def kill_socket(tubesock)
+ # Hijacked connection needs to be notified correctly
+ tubesock.send_data JSON.dump({'cmd' => 'exit'})
+ tubesock.close
+ end
+
+ def handle_message(message, tubesock)
+ # Handle special commands first
+ if (/^exit/.match(message))
+ kill_socket(tubesock)
+ else
+ # Filter out information about run_command, test_command, user or working directory
+ run_command = @submission.execution_environment.run_command
+ test_command = @submission.execution_environment.test_command
+ if !(/root|workspace|#{run_command}|#{test_command}/.match(message))
+ parse_message(message, 'stdout', tubesock)
+ end
+ end
+ end
+
+ def parse_message(message, output_stream, socket, recursive = true)
+ begin
+ parsed = JSON.parse(message)
+ socket.send_data message
+ rescue JSON::ParserError => e
+ # Check wether the message contains multiple lines, if true try to parse each line
+ if ((recursive == true) && (message.include? "\n"))
+ for part in message.split("\n")
+ self.parse_message(part,output_stream,socket,false)
+ end
+ else
+ parsed = {'cmd'=>'write','stream'=>output_stream,'data'=>message}
+ socket.send_data JSON.dump(parsed)
end
end
end
diff --git a/app/views/exercises/_editor.html.slim b/app/views/exercises/_editor.html.slim
index 986af92d..cc5dfe7e 100644
--- a/app/views/exercises/_editor.html.slim
+++ b/app/views/exercises/_editor.html.slim
@@ -1,6 +1,6 @@
#editor.row data-exercise-id=exercise.id data-message-depleted=t('exercises.editor.depleted') data-message-timeout=t('exercises.editor.timeout', permitted_execution_time: @exercise.execution_environment.permitted_execution_time) data-errors-url=execution_environment_errors_path(exercise.execution_environment) data-submissions-url=submissions_path data-user-id=@current_user.id
- .col-sm-3 = render('editor_file_tree', files: @files)
- #frames.col-sm-9
+ div class=(@exercise.hide_file_tree ? 'hidden col-sm-3' : 'col-sm-3') = render('editor_file_tree', files: @files)
+ div id='frames' class=(@exercise.hide_file_tree ? 'col-sm-12' : 'col-sm-9')
- @files.each do |file|
= render('editor_frame', exercise: exercise, file: file)
#autosave-label
diff --git a/app/views/exercises/_form.html.slim b/app/views/exercises/_form.html.slim
index 32365e4a..1002f78a 100644
--- a/app/views/exercises/_form.html.slim
+++ b/app/views/exercises/_form.html.slim
@@ -23,6 +23,10 @@
label
= f.check_box(:public)
= t('activerecord.attributes.exercise.public')
+ .checkbox
+ label
+ = f.check_box(:hide_file_tree)
+ = t('activerecord.attributes.exercise.hide_file_tree')
h2 = t('activerecord.attributes.exercise.files')
ul#files.list-unstyled
= f.fields_for :files do |files_form|
diff --git a/app/views/exercises/implement.html.slim b/app/views/exercises/implement.html.slim
index 33a1d6c3..72ac1b2c 100644
--- a/app/views/exercises/implement.html.slim
+++ b/app/views/exercises/implement.html.slim
@@ -13,44 +13,46 @@
#development-environment
ul.nav.nav-justified.nav-tabs role='tablist'
li.active
- a data-placement='top' data-toggle='tab' data-tooltip=true href='#instructions' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 1')
- i.fa.fa-question
- = t('activerecord.attributes.exercise.instructions')
- li
- a data-placement='top' data-toggle='tab' data-tooltip=true href='#workspace' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 2')
+ a data-placement='top' data-toggle='tab' data-tooltip=true href='#workspace' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 1')
i.fa.fa-code
= t('.workspace')
li
- a data-placement='top' data-toggle='tab' data-tooltip=true href='#outputInformation' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 3')
+ a data-placement='top' data-toggle='tab' data-tooltip=true href='#outputInformation' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 2')
i.fa.fa-terminal
= t('.output')
li
- a data-placement='top' data-toggle='tab' data-tooltip=true href='#progress' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 4')
+ a data-placement='top' data-toggle='tab' data-tooltip=true href='#progress' role='tab' title=t('shared.tooltips.shortcut', shortcut: 'ALT + 3')
i.fa.fa-line-chart
= t('.progress')
hr
.tab-content
- #instructions.tab-pane.active
- p = render_markdown(@exercise.instructions)
- br
- p.text-center
- a#start.btn.btn-lg.btn-success
- i.fa.fa-code
- = t('.start')
- #workspace.tab-pane = render('editor', exercise: @exercise, files: @files, submission: @submission)
+ #workspace.tab-pane.active = render('editor', exercise: @exercise, files: @files, submission: @submission)
#outputInformation.tab-pane data-message-no-output=t('.no_output')
#hint
.panel.panel-warning
.panel-heading = t('.hint')
.panel-body
- #output
- pre = t('.no_output_yet')
- - if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
- #flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
- .panel-heading = 'Gain more insights here'
- .panel-body
+ .row
+ / #output-col1.col-sm-12
+ #output-col1
+ // todo set to full width if turtle isnt used
+ #prompt.input-group.hidden
+ span.input-group-addon = 'Your input'
+ input#prompt-input.form-control type='text'
+ span.input-group-btn
+ button#prompt-submit.btn.btn-primary type="button" = 'Send'
+ #output
+ pre = t('.no_output_yet')
+ - if CodeOcean::Config.new(:code_ocean).read[:flowr][:enabled]
+ #flowrHint.panel.panel-info data-url=CodeOcean::Config.new(:code_ocean).read[:flowr][:url] role='tab'
+ .panel-heading = 'Gain more insights here'
+ .panel-body
+ #output-col2.col-lg-5.col-md-5
+ #turtlediv
+ // todo what should the canvas default size be?
+ canvas#turtlecanvas.hidden width=400 height=400 style='border-style:solid;border-width:thin'
#progress.tab-pane
#results
h2 = t('.results')
@@ -79,4 +81,4 @@
- if qa_url
#questions-column
#questions-holder data-url="#{qa_url}/qa/index/#{@exercise.id}/#{@user_id}"
- = qa_js_tag
\ No newline at end of file
+ = qa_js_tag
diff --git a/app/views/exercises/show.html.slim b/app/views/exercises/show.html.slim
index 6456c3c0..67185046 100644
--- a/app/views/exercises/show.html.slim
+++ b/app/views/exercises/show.html.slim
@@ -14,6 +14,7 @@ h1
= row(label: 'exercise.team', value: @exercise.team ? link_to(@exercise.team, @exercise.team) : nil)
= row(label: 'exercise.maximum_score', value: @exercise.maximum_score)
= row(label: 'exercise.public', value: @exercise.public?)
+= row(label: 'exercise.hide_file_tree', value: @exercise.hide_file_tree?)
= row(label: 'exercise.embedding_parameters') do
= content_tag(:input, nil, class: 'form-control', readonly: true, value: embedding_parameters(@exercise))
diff --git a/config/docker.yml.erb b/config/docker.yml.erb
index cf38ac79..b44497fd 100644
--- a/config/docker.yml.erb
+++ b/config/docker.yml.erb
@@ -7,7 +7,9 @@ default: &default
development:
<<: *default
host: tcp://192.168.59.104:2376
- workspace_root: <%= File.join('/', 'shared', Rails.env) %>
+ ws_host: ws://192.168.59.104:2376
+ #workspace_root: <%= File.join('/', 'shared', Rails.env) %>
+ workspace_root: <%= Rails.root.join('tmp', 'files', Rails.env) %>
production:
<<: *default
diff --git a/config/locales/de.yml b/config/locales/de.yml
index cb8ef305..9cb546b3 100644
--- a/config/locales/de.yml
+++ b/config/locales/de.yml
@@ -28,6 +28,7 @@ de:
execution_environment: Ausführungsumgebung
execution_environment_id: Ausführungsumgebung
files: Dateien
+ hide_file_tree: Dateibaum verstecken
instructions: Anweisungen
maximum_score: Erreichbare Punktzahl
public: Öffentlich
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e726557d..d7fd84a9 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -28,6 +28,7 @@ en:
execution_environment: Execution Environment
execution_environment_id: Execution Environment
files: Files
+ hide_file_tree: Hide File Tree
instructions: Instructions
maximum_score: Maximum Score
public: Public
diff --git a/db/migrate/20150922125415_add_hide_file_tree_to_exercises.rb b/db/migrate/20150922125415_add_hide_file_tree_to_exercises.rb
new file mode 100644
index 00000000..9890c35b
--- /dev/null
+++ b/db/migrate/20150922125415_add_hide_file_tree_to_exercises.rb
@@ -0,0 +1,5 @@
+class AddHideFileTreeToExercises < ActiveRecord::Migration
+ def change
+ add_column :exercises, :hide_file_tree, :boolean
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 7f3be339..d895da3f 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 20150903152727) do
+ActiveRecord::Schema.define(version: 20150922125415) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -79,6 +79,7 @@ ActiveRecord::Schema.define(version: 20150903152727) do
t.string "user_type"
t.string "token"
t.integer "team_id"
+ t.boolean "hide_file_tree"
end
create_table "external_users", force: true do |t|
diff --git a/lib/docker_client.rb b/lib/docker_client.rb
index 58984ddc..3562da13 100644
--- a/lib/docker_client.rb
+++ b/lib/docker_client.rb
@@ -11,6 +11,7 @@ class DockerClient
RETRY_COUNT = 2
attr_reader :container
+ attr_reader :socket
def self.check_availability!
Timeout.timeout(config[:connection_timeout]) { Docker.version }
@@ -41,7 +42,12 @@ class DockerClient
'Memory' => execution_environment.memory_limit.megabytes,
'NetworkDisabled' => !execution_environment.network_enabled?,
'OpenStdin' => true,
- 'StdinOnce' => true
+ 'StdinOnce' => true,
+ # required to expose standard streams over websocket
+ 'AttachStdout' => true,
+ 'AttachStdin' => true,
+ 'AttachStderr' => true,
+ 'Tty' => true
}
end
@@ -52,6 +58,29 @@ class DockerClient
}
end
+ def create_socket(container, stderr=false)
+ # todo factor out query params
+ # todo separate stderr
+ query_params = 'logs=1&stream=1&' + (stderr ? 'stderr=1' : 'stdout=1&stdin=1')
+
+ # Headers are required by Docker
+ headers = {'Origin' => 'http://localhost'}
+
+ socket = Faye::WebSocket::Client.new(DockerClient.config['ws_host'] + '/containers/' + @container.id + '/attach/ws?' + query_params, [], :headers => headers)
+
+ socket.on :error do |event|
+ Rails.logger.info "Websocket error: " + event.message
+ end
+ socket.on :close do |event|
+ Rails.logger.info "Websocket closed."
+ end
+ socket.on :open do |event|
+ Rails.logger.info "Websocket created."
+ kill_after_timeout(container)
+ end
+ socket
+ end
+
def copy_file_to_workspace(options = {})
FileUtils.cp(options[:file].native_file.path, local_file_path(options))
end
@@ -118,14 +147,71 @@ class DockerClient
#(tries += 1) <= RETRY_COUNT ? retry : raise(error)
end
- [:run, :test].each do |cause|
- define_method("execute_#{cause}_command") do |submission, filename, &block|
- command = submission.execution_environment.send(:"#{cause}_command") % command_substitutions(filename)
- create_workspace_files = proc { create_workspace_files(container, submission) }
- execute_command(command, create_workspace_files, block)
+ def execute_websocket_command(command, before_execution_block, output_consuming_block)
+ @container = DockerContainerPool.get_container(@execution_environment)
+ if @container
+ before_execution_block.try(:call)
+ # todo catch exception if socket could not be created
+ @socket ||= create_socket(@container)
+ # Newline required to flush
+ @socket.send command + "\n"
+ {status: :container_running, socket: @socket, container: @container}
+ else
+ {status: :container_depleted}
end
end
+ def kill_after_timeout(container)
+ """
+ We need to start a second thread to kill the websocket connection,
+ as it is impossible to determine whether further input is requested.
+ """
+ Thread.new do
+ timeout = @execution_environment.permitted_execution_time.to_i # seconds
+ sleep(timeout)
+ Rails.logger.info("Killing container after timeout of " + timeout.to_s + " seconds.")
+ kill_container(container)
+ end
+ end
+
+ def kill_container(container)
+ """
+ Please note that we cannot properly recycle containers when using
+ websockets because it is impossible to determine whether a program
+ is still running.
+ """
+ # remove container from pool, then destroy it
+ (DockerContainerPool.config[:active]) ? DockerContainerPool.remove_from_all_containers(container, @execution_environment) :
+
+ #destroy container
+ self.class.destroy_container(container)
+
+ # if we recylce containers, we start a fresh one
+ if(DockerContainerPool.config[:active] && RECYCLE_CONTAINERS)
+ # create new container and add it to @all_containers and @containers.
+ container = self.class.create_container(@execution_environment)
+ DockerContainerPool.add_to_all_containers(container, @execution_environment)
+ end
+ end
+
+ def execute_run_command(submission, filename, &block)
+ """
+ Run commands by attaching a websocket to Docker.
+ """
+ command = submission.execution_environment.send(:"run_command") % command_substitutions(filename)
+ create_workspace_files = proc { create_workspace_files(container, submission) }
+ execute_websocket_command(command, create_workspace_files, block)
+ end
+
+ def execute_test_command(submission, filename, &block)
+ """
+ Stick to existing Docker API with exec command.
+ """
+ command = submission.execution_environment.send(:"test_command") % command_substitutions(filename)
+ create_workspace_files = proc { create_workspace_files(container, submission) }
+ execute_command(command, create_workspace_files, block)
+ end
+
def self.find_image_by_tag(tag)
Docker::Image.all.detect { |image| image.info['RepoTags'].flatten.include?(tag) }
end
diff --git a/lib/py_unit_adapter.rb b/lib/py_unit_adapter.rb
index 29a47ddf..0e80c593 100644
--- a/lib/py_unit_adapter.rb
+++ b/lib/py_unit_adapter.rb
@@ -1,5 +1,5 @@
class PyUnitAdapter < TestingFrameworkAdapter
- COUNT_REGEXP = /Ran (\d+) tests/
+ COUNT_REGEXP = /Ran (\d+) test/
FAILURES_REGEXP = /FAILED \(failures=(\d+)\)/
def self.framework_name
diff --git a/webpython/Dockerfile b/webpython/Dockerfile
new file mode 100644
index 00000000..54d6d4ad
--- /dev/null
+++ b/webpython/Dockerfile
@@ -0,0 +1,12 @@
+FROM ubuntu:14.04
+MAINTAINER "Martin v. Löwis"
+RUN locale-gen en_US.UTF-8
+ENV LANG en_US.UTF-8
+ENV PYTHONPATH $PYTHONPATH:/usr/lib/python3.4:/workspace
+ENV PATH $PATH:/usr/lib/python3.4
+ADD assess.py /usr/lib/python3.4/assess.py
+ADD webpython.py /usr/lib/python3.4/webpython.py
+RUN rm /usr/lib/python3.4/turtle.py
+ADD turtle.py /usr/lib/python3.4/turtle.py
+RUN adduser --disabled-password --gecos Python python
+USER python
\ No newline at end of file
diff --git a/webpython/Makefile b/webpython/Makefile
new file mode 100644
index 00000000..76f102b7
--- /dev/null
+++ b/webpython/Makefile
@@ -0,0 +1,58 @@
+CODEMIRROR=html/js/codemirror.js
+
+all: logs
+
+logs:
+ mkdir logs
+
+# These are put into source control
+generated: $(CODEMIRROR)
+
+
+$(CODEMIRROR): CodeMirror/lib/codemirror.js CodeMirror/mode/python/python.js
+ (cd CodeMirror; bin/compress codemirror python) > $@
+
+run: all kill
+ twistd -l logs/webpython.log -y webpython.tac
+
+runpy: killpy
+ twistd --pidfile pylaunch.pid -l logs/pylaunch.log -y pylaunch.tac
+
+manager: all killmanager
+ twistd --pidfile manager.pid -l logs/manager.log -y manager.tac
+
+kill:
+ if [ -f twistd.pid ];\
+ then\
+ kill `cat twistd.pid`;\
+ fi
+
+killpy:
+ if [ -f pylaunch.pid ];\
+ then\
+ kill `cat pylaunch.pid`;\
+ fi
+
+killmanager:
+ if [ -f manager.pid ];\
+ then\
+ kill `cat manager.pid`;\
+ fi
+
+docker:
+ docker.io build --rm -t webpython .
+
+update:
+ git pull
+ make docker
+ service webpython-worker restart
+
+rmexited:
+ docker.io ps -a|grep 'Exit '|awk '{print $$1;}'|xargs docker.io rm
+
+rmi:
+ docker.io rmi $$(docker.io images | grep "^" | awk '{print $$3;}')
+
+killold:
+ -docker.io ps | egrep 'hours? ago' |awk '{print $$1}'|xargs docker.io kill
+ -killall -o 30m -9 python3
diff --git a/webpython/README.md b/webpython/README.md
new file mode 100644
index 00000000..e95eb83d
--- /dev/null
+++ b/webpython/README.md
@@ -0,0 +1,47 @@
+Local setup
+===========
+
+1. `git checkout webpython-hybrid`
+2. Make sure to install all dependencies and migrations:
+
+ rake db:migrate
+ bundle install
+
+3. Create a new docker image containing the Turtle library and the i/o wrapper:
+
+ cd webpython
+ docker build -t IMAGE_NAME .
+
+4. Configure your Docker host at `config/docker.yml.erb`. Make sure to add a websocket host, for example like this (this is probably different for you):
+
+ host: tcp://localhost:2375
+ ws_host: ws://localhost:2375
+
+5. Run the CodeOcean server with `rails s -p 3333`
+
+6. Login with admin@example.org (pw: admin) and create a new execution environment picking the newly created Docker image from the dropdown. Set the initial command to:
+
+ cd /usr/lib/python3.4 && python3 webpython.py
+
+7. Create a new exercise for the newly created execution environment with an arbritrary main file.
+8. Implement the exercise. The code below can be used as an example to see the canvas and I/O in action:
+
+ import turtle
+ wn = turtle.Screen()
+ alex = turtle.Turtle()
+
+ # i/o test
+ print("hello!")
+ print("please enter your name")
+ name = input()
+ print("your name is", name)
+
+ # canvas test
+ alex.forward(50)
+ alex.right(90)
+ alex.forward(30)
+ alex.right(90)
+ alex.forward(30)
+
+ wn.mainloop()
+
diff --git a/webpython/assess.py b/webpython/assess.py
new file mode 100644
index 00000000..b14a13b9
--- /dev/null
+++ b/webpython/assess.py
@@ -0,0 +1,234 @@
+import webpython, contextlib, io, json, sys, turtle, ast
+from webpython import shell
+
+turtle_operations = []
+bindings = {}
+
+class RecordingPen:
+ _pen = None
+ _screen = None
+ def __init__(self):
+ self.operations = turtle_operations
+ self._pos = (0,0)
+ turtle_operations.append(('__init__',()))
+
+ def reset(self):
+ turtle_operations.clear()
+
+ def onclick(self, fun, btn=1, add=None):
+ self.operations.append(('onclick', (fun,)))
+ def eventfun(event):
+ fun(event.x, event.y)
+ bindings[''] = eventfun
+
+ def goto(self, x, y):
+ self._pos = (x,y)
+ self.operations.append(('goto', (x,y)))
+
+ def pos(self):
+ self.operations.append(('pos', ()))
+ return self._pos
+
+ def __getattr__(self, method):
+ def func(*args):
+ self.operations.append((method, args))
+ return func
+
+class FakeCanvas(turtle.WebCanvas):
+ def flushbatch(self):
+ pass
+
+ def get_width(self):
+ return 400
+
+ def get_height(self):
+ return 400
+
+ def delete(self, item):
+ pass
+
+ def css(self, key, value):
+ pass
+
+fake_events = []
+def mainloop():
+ while fake_events:
+ e = turtle.Event(fake_events.pop(0))
+ if e.type in bindings:
+ bindings[e.type](e)
+
+turtle.Turtle = RecordingPen
+turtle.WebCanvas = FakeCanvas
+pen = turtle._getpen()
+turtle.mainloop = mainloop
+
+def filter_operations(name):
+ return [o for o in turtle_operations if o[0] == name]
+
+@contextlib.contextmanager
+def capture():
+ global captured_out
+ import sys
+ oldout,olderr = sys.stdout, sys.stderr
+ try:
+ out=[io.StringIO(), io.StringIO()]
+ captured_out = out
+ sys.stdout,sys.stderr = out
+ yield out
+ finally:
+ sys.stdout,sys.stderr = oldout, olderr
+ out[0] = out[0].getvalue()
+ out[1] = out[1].getvalue()
+
+def get_source():
+ message = json.loads(sys.argv[1])
+ return message['data']
+
+def get_ast():
+ s = get_source()
+ return ast.parse(s, "programm.py", "exec")
+
+def has_bare_except():
+ for node in ast.walk(get_ast()):
+ if isinstance(node, ast.ExceptHandler):
+ if node.type is None:
+ return True
+ return False
+
+def runcaptured(prefix='', tracing=None, variables=None, source=''):
+ #message = json.loads(sys.argv[1])
+ #source = prefix + message['data']
+ with open("programm.py", "w", encoding='utf-8') as f:
+ f.write(source)
+ c = compile(source, "programm.py", 'exec')
+ with capture() as out, trace(tracing):
+ if variables is None:
+ variables = {}
+ exec(c, variables)
+ return source, out[0], out[1], variables
+
+def runfunc(func, *args, tracing=None):
+ with capture() as out, trace(tracing):
+ res = func(*args)
+ return out[0], out[1], res
+
+def passed():
+ msg_in = json.loads(sys.argv[1])
+ msg_out = {'cmd':'passed'}
+ msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
+ msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
+ webpython.shell.sendpickle(msg_out)
+
+def failed(msg):
+ msg_in = json.loads(sys.argv[1])
+ msg_out = {'cmd':'failed', 'data':'Dein Programm ist leider falsch:\n'+msg}
+ msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
+ msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
+ webpython.shell.sendpickle(msg_out)
+
+def modified(variables, name, val):
+ if variables.get(name) != val:
+ msg_in = json.loads(sys.argv[1])
+ msg_out = {'cmd':'failed',
+ 'data':('Bitte lösche Deine Zuweisung der Variable %s, '+
+ 'damit wir Dein Programm überprüfen können.') % name}
+ msg_out['lis_outcome_service_url'] = msg_in['lis_outcome_service_url']
+ msg_out['lis_result_sourcedid'] = msg_in['lis_result_sourcedid']
+ webpython.shell.sendpickle(msg_out)
+ return True
+ return False
+
+undefined = object()
+def getvar(variables, name):
+ try:
+ return variables['name']
+ except KeyError:
+ name = name.lower()
+ for k,v in variables.items():
+ if k.lower() == name:
+ return v
+ return undefined
+
+def _match(n1, n2):
+ if n1 == n2:
+ return True
+ if n1 is None or n2 is None:
+ return False
+ return n1.lower() == n2.lower()
+
+class Call:
+ def __init__(self, name, args):
+ self.name = name
+ self.args = args
+ self.calls = []
+ self.current = None
+
+ def findcall(self, f):
+ if _match(self.name, f):
+ return self
+ for c in self.calls:
+ r = c.findcall(f)
+ if r:
+ return r
+ return None
+
+ def calling(self, caller, callee):
+ if _match(self.name, caller):
+ for c in self.calls:
+ if _match(c.name, callee):
+ return True
+ for c in self.calls:
+ if c.calling(caller, callee):
+ return True
+ return False
+
+ def countcalls(self, caller, callee):
+ calls = 0
+ if _match(self.name, caller):
+ for c in self.calls:
+ if _match(c.name, callee):
+ calls += 1
+ return calls
+ for c in self.calls:
+ r = c.countcalls(caller, callee)
+ if r > 0:
+ return r
+ return 0
+
+class Tracing(Call):
+ def __init__(self):
+ Call.__init__(self, None, None)
+
+ def trace(self, frame, event, arg):
+ if event == 'call':
+ c = Call(frame.f_code.co_name, frame.f_locals.copy())
+ cur = self
+ while cur.current:
+ cur = cur.current
+ cur.calls.append(c)
+ cur.current = c
+ return self.trace
+ elif event in ('return', 'exception'):
+ cur = self
+ if not cur.current:
+ # XXX return without call? happens when invocation of top function fails
+ return
+ while cur.current.current:
+ cur = cur.current
+ cur.current = None
+
+ def start(self):
+ sys.settrace(self.trace)
+
+ def stop(self):
+ sys.settrace(None)
+
+@contextlib.contextmanager
+def trace(t):
+ try:
+ if t:
+ t.start()
+ yield
+ finally:
+ if t:
+ t.stop()
diff --git a/webpython/turtle.py b/webpython/turtle.py
new file mode 100644
index 00000000..87e91955
--- /dev/null
+++ b/webpython/turtle.py
@@ -0,0 +1,4078 @@
+#
+# turtle.py: a Tkinter based turtle graphics module for Python
+# Version 1.1b - 4. 5. 2009
+#
+# Copyright (C) 2006 - 2010 Gregor Lingl
+# email: glingl@aon.at
+#
+# This software is provided 'as-is', without any express or implied
+# warranty. In no event will the authors be held liable for any damages
+# arising from the use of this software.
+#
+# Permission is granted to anyone to use this software for any purpose,
+# including commercial applications, and to alter it and redistribute it
+# freely, subject to the following restrictions:
+#
+# 1. The origin of this software must not be misrepresented; you must not
+# claim that you wrote the original software. If you use this software
+# in a product, an acknowledgment in the product documentation would be
+# appreciated but is not required.
+# 2. Altered source versions must be plainly marked as such, and must not be
+# misrepresented as being the original software.
+# 3. This notice may not be removed or altered from any source distribution.
+
+
+"""
+Turtle graphics is a popular way for introducing programming to
+kids. It was part of the original Logo programming language developed
+by Wally Feurzig and Seymour Papert in 1966.
+
+Imagine a robotic turtle starting at (0, 0) in the x-y plane. After an ``import turtle``, give it
+the command turtle.forward(15), and it moves (on-screen!) 15 pixels in
+the direction it is facing, drawing a line as it moves. Give it the
+command turtle.right(25), and it rotates in-place 25 degrees clockwise.
+
+By combining together these and similar commands, intricate shapes and
+pictures can easily be drawn.
+
+----- turtle.py
+
+This module is an extended reimplementation of turtle.py from the
+Python standard distribution up to Python 2.5. (See: http://www.python.org)
+
+It tries to keep the merits of turtle.py and to be (nearly) 100%
+compatible with it. This means in the first place to enable the
+learning programmer to use all the commands, classes and methods
+interactively when using the module from within IDLE run with
+the -n switch.
+
+Roughly it has the following features added:
+
+- Better animation of the turtle movements, especially of turning the
+ turtle. So the turtles can more easily be used as a visual feedback
+ instrument by the (beginning) programmer.
+
+- Different turtle shapes, gif-images as turtle shapes, user defined
+ and user controllable turtle shapes, among them compound
+ (multicolored) shapes. Turtle shapes can be stretched and tilted, which
+ makes turtles very versatile geometrical objects.
+
+- Fine control over turtle movement and screen updates via delay(),
+ and enhanced tracer() and speed() methods.
+
+- Aliases for the most commonly used commands, like fd for forward etc.,
+ following the early Logo traditions. This reduces the boring work of
+ typing long sequences of commands, which often occur in a natural way
+ when kids try to program fancy pictures on their first encounter with
+ turtle graphics.
+
+- Turtles now have an undo()-method with configurable undo-buffer.
+
+- Some simple commands/methods for creating event driven programs
+ (mouse-, key-, timer-events). Especially useful for programming games.
+
+- A scrollable Canvas class. The default scrollable Canvas can be
+ extended interactively as needed while playing around with the turtle(s).
+
+- A TurtleScreen class with methods controlling background color or
+ background image, window and canvas size and other properties of the
+ TurtleScreen.
+
+- There is a method, setworldcoordinates(), to install a user defined
+ coordinate-system for the TurtleScreen.
+
+- The implementation uses a 2-vector class named Vec2D, derived from tuple.
+ This class is public, so it can be imported by the application programmer,
+ which makes certain types of computations very natural and compact.
+
+- Appearance of the TurtleScreen and the Turtles at startup/import can be
+ configured by means of a turtle.cfg configuration file.
+ The default configuration mimics the appearance of the old turtle module.
+
+- If configured appropriately the module reads in docstrings from a docstring
+ dictionary in some different language, supplied separately and replaces
+ the English ones by those read in. There is a utility function
+ write_docstringdict() to write a dictionary with the original (English)
+ docstrings to disc, so it can serve as a template for translations.
+
+Behind the scenes there are some features included with possible
+extensions in mind. These will be commented and documented elsewhere.
+
+"""
+
+_ver = "turtle 1.1b- - for Python 3.1 - 4. 5. 2009"
+
+# print(_ver)
+
+import types
+import math
+import time
+import inspect
+import sys
+import builtins
+
+from os.path import isfile, split, join
+from copy import deepcopy
+
+_tg_classes = ['TurtleScreen', 'Screen',
+ 'RawTurtle', 'Turtle', 'RawPen', 'Pen', 'Shape', 'Vec2D']
+_tg_screen_functions = ['addshape', 'bgcolor', 'bgpic', 'bye',
+ 'clearscreen', 'colormode', 'delay', 'exitonclick', 'getcanvas',
+ 'getshapes', 'listen', 'mainloop', 'mode', 'numinput',
+ 'onkey', 'onkeypress', 'onkeyrelease', 'onscreenclick', 'ontimer',
+ 'register_shape', 'resetscreen', 'screensize', 'setup',
+ 'setworldcoordinates', 'textinput', 'title', 'tracer', 'turtles', 'update',
+ 'window_height', 'window_width']
+_tg_turtle_functions = ['back', 'backward', 'begin_fill', 'begin_poly', 'bk',
+ 'circle', 'clear', 'clearstamp', 'clearstamps', 'clone', 'color',
+ 'degrees', 'distance', 'dot', 'down', 'end_fill', 'end_poly', 'fd',
+ 'fillcolor', 'filling', 'forward', 'get_poly', 'getpen', 'getscreen', 'get_shapepoly',
+ 'getturtle', 'goto', 'heading', 'hideturtle', 'home', 'ht', 'isdown',
+ 'isvisible', 'left', 'lt', 'onclick', 'ondrag', 'onrelease', 'pd',
+ 'pen', 'pencolor', 'pendown', 'pensize', 'penup', 'pos', 'position',
+ 'pu', 'radians', 'right', 'reset', 'resizemode', 'rt',
+ 'seth', 'setheading', 'setpos', 'setposition', 'settiltangle',
+ 'setundobuffer', 'setx', 'sety', 'shape', 'shapesize', 'shapetransform', 'shearfactor', 'showturtle',
+ 'speed', 'st', 'stamp', 'tilt', 'tiltangle', 'towards',
+ 'turtlesize', 'undo', 'undobufferentries', 'up', 'width',
+ 'write', 'xcor', 'ycor']
+_tg_utilities = ['write_docstringdict', 'done']
+
+__all__ = (_tg_classes + _tg_screen_functions + _tg_turtle_functions +
+ _tg_utilities + ['Terminator']) # + _math_functions)
+
+_alias_list = ['addshape', 'backward', 'bk', 'fd', 'ht', 'lt', 'pd', 'pos',
+ 'pu', 'rt', 'seth', 'setpos', 'setposition', 'st',
+ 'turtlesize', 'up', 'width']
+
+_CFG = {"width" : 0.5, # Screen
+ "height" : 0.75,
+ "canvwidth" : 400,
+ "canvheight": 300,
+ "leftright": None,
+ "topbottom": None,
+ "mode": "standard", # TurtleScreen
+ "colormode": 1.0,
+ "delay": 10,
+ "undobuffersize": 1000, # RawTurtle
+ "shape": "classic",
+ "pencolor" : "black",
+ "fillcolor" : "black",
+ "resizemode" : "noresize",
+ "visible" : True,
+ "language": "english", # docstrings
+ "exampleturtle": "turtle",
+ "examplescreen": "screen",
+ "title": "Python Turtle Graphics",
+ "using_IDLE": False
+ }
+
+def config_dict(filename):
+ """Convert content of config-file into dictionary."""
+ with open(filename, "r") as f:
+ cfglines = f.readlines()
+ cfgdict = {}
+ for line in cfglines:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ try:
+ key, value = line.split("=")
+ except:
+ print("Bad line in config-file %s:\n%s" % (filename,line))
+ continue
+ key = key.strip()
+ value = value.strip()
+ if value in ["True", "False", "None", "''", '""']:
+ value = eval(value)
+ else:
+ try:
+ if "." in value:
+ value = float(value)
+ else:
+ value = int(value)
+ except:
+ pass # value need not be converted
+ cfgdict[key] = value
+ return cfgdict
+
+def readconfig(cfgdict):
+ """Read config-files, change configuration-dict accordingly.
+
+ If there is a turtle.cfg file in the current working directory,
+ read it from there. If this contains an importconfig-value,
+ say 'myway', construct filename turtle_mayway.cfg else use
+ turtle.cfg and read it from the import-directory, where
+ turtle.py is located.
+ Update configuration dictionary first according to config-file,
+ in the import directory, then according to config-file in the
+ current working directory.
+ If no config-file is found, the default configuration is used.
+ """
+ default_cfg = "turtle.cfg"
+ cfgdict1 = {}
+ cfgdict2 = {}
+ if isfile(default_cfg):
+ cfgdict1 = config_dict(default_cfg)
+ if "importconfig" in cfgdict1:
+ default_cfg = "turtle_%s.cfg" % cfgdict1["importconfig"]
+ try:
+ head, tail = split(__file__)
+ cfg_file2 = join(head, default_cfg)
+ except:
+ cfg_file2 = ""
+ if isfile(cfg_file2):
+ cfgdict2 = config_dict(cfg_file2)
+ _CFG.update(cfgdict2)
+ _CFG.update(cfgdict1)
+
+try:
+ readconfig(_CFG)
+except:
+ print ("No configfile read, reason unknown")
+
+
+class Vec2D(tuple):
+ """A 2 dimensional vector class, used as a helper class
+ for implementing turtle graphics.
+ May be useful for turtle graphics programs also.
+ Derived from tuple, so a vector is a tuple!
+
+ Provides (for a, b vectors, k number):
+ a+b vector addition
+ a-b vector subtraction
+ a*b inner product
+ k*a and a*k multiplication with scalar
+ |a| absolute value of a
+ a.rotate(angle) rotation
+ """
+ def __new__(cls, x, y):
+ return tuple.__new__(cls, (x, y))
+ def __add__(self, other):
+ return Vec2D(self[0]+other[0], self[1]+other[1])
+ def __mul__(self, other):
+ if isinstance(other, Vec2D):
+ return self[0]*other[0]+self[1]*other[1]
+ return Vec2D(self[0]*other, self[1]*other)
+ def __rmul__(self, other):
+ if isinstance(other, int) or isinstance(other, float):
+ return Vec2D(self[0]*other, self[1]*other)
+ def __sub__(self, other):
+ return Vec2D(self[0]-other[0], self[1]-other[1])
+ def __neg__(self):
+ return Vec2D(-self[0], -self[1])
+ def __abs__(self):
+ return (self[0]**2 + self[1]**2)**0.5
+ def rotate(self, angle):
+ """rotate self counterclockwise by angle
+ """
+ perp = Vec2D(-self[1], self[0])
+ angle = angle * math.pi / 180.0
+ c, s = math.cos(angle), math.sin(angle)
+ return Vec2D(self[0]*c+perp[0]*s, self[1]*c+perp[1]*s)
+ def __getnewargs__(self):
+ return (self[0], self[1])
+ def __repr__(self):
+ return "(%.2f,%.2f)" % self
+
+
+##############################################################################
+### From here up to line : Tkinter - Interface for turtle.py ###
+### May be replaced by an interface to some different graphics toolkit ###
+##############################################################################
+import time
+
+# guaranteed to exist in Tk
+namedcolors = ["white", "black", "red", "green", "blue", "cyan", "yellow", "magenta"]
+
+class WebImage:
+ pass
+
+class BlankImage(WebImage):
+ pass
+
+class Event:
+ def __init__(self, d):
+ for k,v in d.items():
+ setattr(self, k, v)
+
+class WebCanvas:
+ def __init__(self, shell):
+ self.shell = shell
+ self.bindings = {}
+ self.batch = []
+ self.items = []
+
+ def addbatch(self, msg, *params):
+ self.batch.append([msg,params])
+
+ def flushbatch(self):
+ if not self.batch:
+ return
+ self.shell.sendpickle({'cmd':'turtlebatch',
+ 'batch':self.batch})
+ self.batch = []
+
+ # Locally implemented
+ def after(self, delay):
+ time.sleep(delay/1000)
+
+ def tag_bind(self, item, event, function, add):
+ self.bindings[event] = function
+
+ def tag_unbind(self, event):
+ del self.bindings[event]
+
+ def canvasx(self, x):
+ return x
+
+ def canvasy(self, y):
+ return y
+
+ # batched
+ def create_image(self, w, h, image):
+ self.items.append({'type':'image','image':image})
+ self.addbatch('create_image', image)
+ return len(self.items)-1
+
+ def create_line(self):
+ self.items.append({'type':'line',
+ 'fill': '',
+ 'coords':[0,0,0,0],
+ 'width':2,
+ 'capstyle':'round'})
+ self.addbatch('create_line')
+ return len(self.items)-1
+
+ def create_polygon(self):
+ self.items.append({'type':'polygon',
+ 'coords':[0,0,0,0,0,0]
+ })
+ self.addbatch('create_polygon')
+ return len(self.items)-1
+
+ def coords(self, item, coords=None):
+ if coords is None:
+ return self.items[item]['coords']
+ self.items[item]['coords'] = coords
+ self.addbatch('coords', item, coords)
+
+ def itemconfigure(self, item, **args):
+ assert len(args) == 1
+ key, value = list(args.items())[0]
+ self.items[item][key] = value
+ self.addbatch('itemconfigure', item, key, value)
+
+ def update(self):
+ self.addbatch('update')
+ self.flushbatch()
+
+ # XXX TODO
+ def tag_raise(self, item):
+ pass
+
+ def __getattr__(self, attr):
+ def call(*args):
+ self.flushbatch()
+ self.shell.sendpickle({'cmd':'turtle',
+ 'action':attr,
+ 'args':args})
+ result = self.shell.receivemsg()
+ if result['cmd'] == 'result':
+ return result.get('result') # might be None as JSON leaves out undefined values
+ else:
+ raise getattr(builtins, result['exception'])(result['message'])
+ return call
+
+ def getevent(self):
+ msg = self.shell.receivecanvas()
+ event = Event(msg)
+ if event.type in self.bindings:
+ self.bindings[event.type](event)
+
+
+class TurtleScreenBase(object):
+ """Provide the basic graphics functionality.
+ Interface between Tkinter and turtle.py.
+
+ To port turtle.py to some different graphics toolkit
+ a corresponding TurtleScreenBase class has to be implemented.
+ """
+
+ @staticmethod
+ def _blankimage():
+ """return a blank image object
+ """
+ return BlankImage()
+
+ @staticmethod
+ def _image(filename):
+ """return an image object containing the
+ imagedata from a gif-file named filename.
+ """
+ raise IOError("opening files is not permitted in this implementation")
+
+ def __init__(self, cv):
+ self.cv = cv
+ self.canvwidth = cv.get_width()
+ self.canvheight = cv.get_height()
+ self.xscale = self.yscale = 1.0
+
+ def _createpoly(self):
+ """Create an invisible polygon item on canvas self.cv)
+ """
+ return self.cv.create_polygon()
+
+ def _drawpoly(self, polyitem, coordlist, fill=None,
+ outline=None, width=None, top=False):
+ """Configure polygonitem polyitem according to provided
+ arguments:
+ coordlist is sequence of coordinates
+ fill is filling color
+ outline is outline color
+ top is a boolean value, which specifies if polyitem
+ will be put on top of the canvas' displaylist so it
+ will not be covered by other items.
+ """
+ cl = []
+ for x, y in coordlist:
+ cl.append(x * self.xscale)
+ cl.append(-y * self.yscale)
+ self.cv.coords(polyitem, cl)
+ if fill is not None:
+ self.cv.itemconfigure(polyitem, fill=fill)
+ if outline is not None:
+ self.cv.itemconfigure(polyitem, outline=outline)
+ if width is not None:
+ self.cv.itemconfigure(polyitem, width=width)
+ if top:
+ self.cv.tag_raise(polyitem)
+
+ def _createline(self):
+ """Create an invisible line item on canvas self.cv)
+ """
+ return self.cv.create_line()
+
+ def _drawline(self, lineitem, coordlist=None,
+ fill=None, width=None, top=False):
+ """Configure lineitem according to provided arguments:
+ coordlist is sequence of coordinates
+ fill is drawing color
+ width is width of drawn line.
+ top is a boolean value, which specifies if polyitem
+ will be put on top of the canvas' displaylist so it
+ will not be covered by other items.
+ """
+ if coordlist is not None:
+ cl = []
+ for x, y in coordlist:
+ cl.append(x * self.xscale)
+ cl.append(-y * self.yscale)
+ self.cv.coords(lineitem, cl)
+ if fill is not None:
+ self.cv.itemconfigure(lineitem, fill=fill)
+ if width is not None:
+ self.cv.itemconfigure(lineitem, width=width)
+ if top:
+ self.cv.tag_raise(lineitem)
+
+ def _delete(self, item):
+ """Delete graphics item from canvas.
+ If item is"all" delete all graphics items.
+ """
+ self.cv.delete(item)
+
+ def _update(self):
+ """Redraw graphics items on canvas
+ """
+ self.cv.update()
+
+ def _delay(self, delay):
+ """Delay subsequent canvas actions for delay ms."""
+ self.cv.after(delay)
+
+ def _iscolorstring(self, color):
+ """Check if the string color is a legal Tkinter color string.
+ """
+ if color[0] == '#' and len(color) % 3 == 1:
+ try:
+ int(color[1:], 16)
+ except ValueError:
+ return False
+ return True
+ return color in namedcolors
+
+ def _bgcolor(self, color=None):
+ """Set canvas' backgroundcolor if color is not None,
+ else return backgroundcolor."""
+ if color is not None:
+ self.cv.css('background-color', color)
+ self._update()
+ else:
+ return self.cv.css('background-color')
+
+ def _write(self, pos, txt, align, font, pencolor):
+ """Write txt at pos in canvas with specified font
+ and color.
+ Return text item and x-coord of right bottom corner
+ of text's bounding box."""
+ x, y = pos
+ x = x * self.xscale
+ y = y * self.yscale
+ anchor = {"left":"sw", "center":"s", "right":"se" }
+ item = self.cv.create_text(x-1, -y, text = txt, anchor = anchor[align],
+ fill = pencolor, font = font)
+ x0, y0, x1, y1 = self.cv.bbox(item)
+ self.cv.update()
+ return item, x1-1
+
+## def _dot(self, pos, size, color):
+## """may be implemented for some other graphics toolkit"""
+
+ def _onclick(self, item, fun, num=1, add=None):
+ """Bind fun to mouse-click event on turtle.
+ fun must be a function with two arguments, the coordinates
+ of the clicked point on the canvas.
+ num, the number of the mouse-button defaults to 1
+ """
+ if fun is None:
+ self.cv.tag_unbind(item, "" % num)
+ else:
+ def eventfun(event):
+ x, y = (self.cv.canvasx(event.x)/self.xscale,
+ -self.cv.canvasy(event.y)/self.yscale)
+ fun(x, y)
+ self.cv.tag_bind(item, "" % num, eventfun, add)
+
+ def _onrelease(self, item, fun, num=1, add=None):
+ """Bind fun to mouse-button-release event on turtle.
+ fun must be a function with two arguments, the coordinates
+ of the point on the canvas where mouse button is released.
+ num, the number of the mouse-button defaults to 1
+
+ If a turtle is clicked, first _onclick-event will be performed,
+ then _onscreensclick-event.
+ """
+ if fun is None:
+ self.cv.tag_unbind(item, "