This is an attempt to summarize key parts of BlockPy's execution model. Expect most of it to be out of date by the time I'm done writing, but it's at least true here and now, May 2nd 2023!
BlockPy is composed of components. Most interesting stuff is in one of the components, accessible via the blockpy
instance (usually this.main
internally) in its .components
field.
The UI is KnockoutJS, so you have a bunch of HTML templates littered around the codebase, with various data-bind
attributes connecting to the actual UI logic (which unfortunately just gets stuffed in blockpy.js
.
The interface is done via KnockoutJS. That was a mistake but here we are. The UI is broken off into separate components, one of which is the list of possible Editor
instances. The most critical one is the Python Editor (editor/python.js
). You bind the click
event handler to ui.execute.run
:
|
<div class="btn-group mr-2" role="group" aria-label="Run Group"> |
|
<button type="button" class="btn blockpy-run notransition" |
|
data-bind="click: ui.execute.run, |
|
css: {'blockpy-run-running': ui.execute.isRunning}"> |
|
<span class="fas fa-play"></span> <span data-bind="text: ui.execute.runLabel"></span> |
|
</button> |
|
</div> |
That function is stuffed into the big main blockpy.js
file, because apparently Past Me chose to forget everything he knew about modular design:
|
run: () => { |
|
if (model.status.onExecution() === StatusState.ACTIVE) { |
|
if (typeof PygameLib !== "undefined" && PygameLib.running) { |
|
PygameLib.StopPygame(); |
|
} |
|
model.status.onExecution(StatusState.READY); |
|
} else { |
|
self.components.engine.delayedRun(); |
|
} |
That is really just dispatching to delayedRun
(there are performance reasons for this, believe it or not), which in turn is responsible for calling run
(there are not performance reasons for that, it's just leftover structure from an older hack):
|
delayedRun(disableFeedback=false) { |
|
//this.main.model.status.onExecution(StatusState.ACTIVE); |
|
//$(".blockpy-run").addClass("blockpy-run-running"); |
|
this.run(disableFeedback); |
|
//setTimeout(this.run.bind(this), 1); |
|
} |
|
|
|
stop() { |
|
|
|
} |
|
|
|
run(disableFeedback=false) { |
|
this.configuration = this.configurations.run.use(this); |
|
let execution = this.execute().then( |
|
this.configuration.success.bind(this.configuration), |
|
this.configuration.failure.bind(this.configuration) |
|
); |
|
if (!this.main.model.assignment.settings.disableFeedback() && !disableFeedback) { |
|
execution.then(() => { |
|
this.configuration.provideSecretError(); |
|
return this.onRun(); |
|
}); |
|
} else { |
|
execution.then(this.configuration.showErrors.bind(this.configuration)); |
|
} |
|
execution.then(this.configuration.finally.bind(this.configuration)); |
|
} |
Okay the actual run
function finally gets a little interesting. This is in engine.js
and requires you to understand the Configuration
hierarchy that we use.
We need to be able to run the user's code in various ways. The different Configuration
classes allow us to reuse functionality between those "various ways". In addition to the methods shown, they also have a bunch of methods like print()
, input()
, openFile()
, etc. that work differently depending on how the code is meant to be executed (e.g., the Student
variant's print
puts text on the BlockPy console, the Instructor
variant's print
puts it in the developer console).
Anyway, the critical thing is that if you are calling the run
function in the BlockPyEngine
component, then it going to set the current configuration
for the engine, and then delegate out to the general execute
function:
|
execute() { |
|
this.main.model.status.onExecution(StatusState.ACTIVE); |
|
return Sk.misceval.asyncToPromise(() => |
|
Sk.importMainWithBody(this.configuration.filename, false, |
|
this.configuration.code, true, |
|
this.configuration.sysmodules) |
|
); |
|
} |
That is where the actual Skulpt
magic happens, calling Sk.importMainWithBody
with the appropriate data and returning a promise for when it is finished. After all is done, we call the relevant Configuration.success
and Configuration.failure
callbacks and eventually the Configuration.finally
handlers. Assuming we we have not disabled feedback, then we repeat this process for the onRun
.
|
onRun() { |
|
this.configuration = this.configurations.onRun.use(this); |
|
this.execute().then( |
|
this.configuration.success.bind(this.configuration), |
|
this.configuration.failure.bind(this.configuration) |
|
) |
|
.then(this.configuration.finally.bind(this.configuration)) |
|
.then(this.executionEnd_.bind(this)); |
|
} |
Basically the same process as before, just a little simpler. We are now running the instructor control script: the contents of the assignment's on_run.py
file wrapped with our template that does all the boilerplate stuff for executing Pedal:
|
export const WRAP_INSTRUCTOR_CODE = function (studentFiles, instructorCode, quick, isSafe) { |
|
let safeCode = JSON.stringify(studentFiles); |
|
let skip_tifa = quick ? "True": "False"; |
|
|
|
// TODO: Add in Sk.queuedInput to be passed in |
|
|
|
return ` |
|
# Support our sysmodules hack by clearing out any lingering old data |
|
from pedal.core.report import MAIN_REPORT |
|
MAIN_REPORT.clear() |
|
|
|
from bakery import student_tests |
|
student_tests.reset() |
|
|
|
from utility import * |
|
|
|
# Load in some commonly used tools |
|
from pedal.cait.cait_api import parse_program |
|
from pedal.sandbox.commands import * |
|
from pedal.core.commands import * |
|
|
|
from pedal.environments.blockpy import setup_environment |
|
# Do we execute student's code? |
|
skip_run = get_model_info('assignment.settings.disableInstructorRun') |
|
inputs = None if skip_run else get_model_info('execution.input') |
|
|
|
# Set the seed to the submission ID by default? |
|
from pedal.questions import set_seed |
|
set_seed(str(get_model_info("submission.id"))) |
|
|
|
# Initialize the BlockPy environment |
|
pedal = setup_environment(skip_tifa=${skip_tifa}, |
|
skip_run=skip_run, |
|
inputs=inputs, |
|
main_file='answer.py', |
|
files=${safeCode}) |
|
student = pedal.fields['student'] |
|
|
|
# TODO: Refactor resolver to return instructions |
|
# Monkey-patch questions |
|
#from pedal import questions |
|
#questions.show_question = set_instructions |
|
|
|
${INSTRUCTOR_MARKER} |
|
${instructorCode} |
|
|
|
# Resolve everything |
|
from pedal.resolvers.simple import resolve |
|
final = resolve() |
|
SUCCESS = final.success |
|
SCORE = final.score |
|
CATEGORY = final.category |
|
LABEL = final.title |
|
MESSAGE = final.message |
|
DATA = final.data |
|
HIDE = final.hide_correctness |
|
|
|
# Handle questions |
|
if final.instructions: |
|
set_instructions(final.instructions[-1].message) |
|
|
|
# Handle positive feedback |
|
POSITIVE = [] |
|
for positive in final.positives: |
|
message = positive.message |
|
if not positive: |
|
message = positive.else_message |
|
POSITIVE.append({ |
|
"title": positive.title, |
|
"label": positive.label, |
|
"message": message |
|
}) |
|
|
|
# Handle system messages |
|
for system in final.systems: |
|
if system.label == 'log': |
|
console_log(system.title, system.message); |
|
if system.label == 'debug': |
|
console_debug(system.title, system.message); |
|
|
|
`; |
|
}; |
Once that's all done, we post-process the results in various ways, including calling this.main.components.feedback.presentFeedback(results)
, updating the submission's data locally and on the backend, and so on:
|
success(module) { |
|
// TODO Logging!!!! |
|
//console.log("OnRun success"); |
|
// TODO: Actually parse results |
|
this.main.model.execution.instructor.globals = Sk.globals; |
|
this.main.model.execution.instructor.sysmodules = Sk.sysmodules; |
|
Sk.globals = {}; |
|
let results = module.$d.on_run.$d; |
|
this.main.components.feedback.presentFeedback(results); |
|
this.main.model.execution.reports["instructor"]["success"] = true; |
|
let success = Sk.ffi.remapToJs(results.SUCCESS); |
|
this.main.model.submission.correct(success || this.main.model.submission.correct()); |
|
// Cannot exceed 1 point, cannot go below 0 points |
|
let score = Sk.ffi.remapToJs(results.SCORE); |
|
score = Math.max(0, Math.min(1, score)); |
|
let oldScore = this.main.model.submission.score(); |
|
score = Math.max(oldScore, score); |
|
this.main.model.submission.score(score); |
|
// Hide status |
|
let hide = Sk.ffi.remapToJs(results.HIDE); |
|
// And fire the result! |
|
this.main.components.server.updateSubmission(score, success, hide, false); |
|
this.main.model.status.onExecution(StatusState.READY); |
|
//after(module); |
|
|
|
/*if (success && this.main.model.configuration.callbacks.success) { |
|
this.main.model.configuration.callbacks.success(this.main.model.assignment.id()); |
|
}*/ |
|
|
|
if (!Sk.executionReports.instructor.scrolling) { |
|
try { |
|
this.main.components.console.scrollToBottom(); |
|
} catch (e) { |
|
} |
|
} |
|
} |
Actually intepretting the feedback is a fairly tedious process, but the Feedback
component is fairly self-contained. If you poke around the src/feedback.js
file, you'll find its HTML template and the various logic for controlling it.
|
/** |
|
* Present any accumulated feedback |
|
*/ |
|
presentFeedback(executionResults) { |
|
this.updateFeedback(executionResults); |
|
|
|
this.category.off("click"); |
|
if (this.main.model.display.instructor()) { |
|
this.updateFullFeedback(executionResults); |
|
} |
|
|
|
// TODO: Logging |
|
//this.main.components.server.logEvent("feedback", category+"|"+label, message); |
|
|
|
this.notifyFeedbackUpdate(); |
|
}; |
There's so much I know more now about interfacing more directly with the Skulpt data, but the code mostly works.
God, I wish I could just sit down and rewrite all of this from scratch, using TypeScript, a better UI library, and some sane decisions in terms of modular separation of concerns. I'm this guy: