Embedding a Python REPL in a WPF application
Recently I started active development of a WPF application and confronted a problem of UI interaction testing. While I was getting away with testing models and viewmodels relatively painlessly (most of the time anyway), and there’s apparently a way to test animations and other interactions with Blend, I missed the ability to see how well does the UI behave in reaction to change in the domain model.
I initially created a testbed for my application which basically was a window with the UI in question and a panel of buttons, that would launch different actions in model. But that didn’t feel right – JavaScript developers have the luxury of a REPL in the browser, and a possibility to inspect and manipulate UI elements.
Of course, there’s the WPF Inspector, which is an amazing tool, and will probably be suffictient for most applications, but it is more mouse-centric than I’d like.
After doing some research, I’ve found that the easiest option to add an interactive REPL to a WPF application would be to use IronPython. It also turned out that there’s a great implementation of a “REPL-textbox” control – IronPythonConsole. It uses AvalonEdit and has syntax highlighting, autocompletion, command history and probably some more cool features.
Project setup
You’ll need to add to your packages.config
and then reference these libraries:
- AvalonEdit
- IronPython
- IronPytonConsole
Next, you’ll need to put an IronPythonConsoleControl
somewhere, either in a separate window, or (as I did it) side-by-side with your application UI:
<Window x:Class="UITestbed.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:pythonConsoleControl="clr-namespace:PythonConsoleControl;assembly=PythonConsoleControl"
UseLayoutRounding="True"
WindowStartupLocation="CenterScreen"
Loaded="MainWindow_OnLoaded">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<pythonConsoleControl:IronPythonConsoleControl
x:Name="PythonRepl" Grid.Column="0"/>
<ContentPresenter
x:Name="AppUi" Grid.Column="1"/>
</Grid>
</Window>
You’ll need to initialize the Python’s REPL environment so you can actually do stuff to your application’s objects. This is done by simply adding variables to current Python interpreter environment, which is accessible at IronPythonConsoleControl.Console.ScriptScope
. However, it is only safe to do this after the initialization is complete.
So, in your MainWindow
class you put this code:
public MainWindow()
{
InitializeComponent();
// ... create/initialize your application objects here
this.AppUi.Content = /* ... your UI root ... */;
// is there a better way to set word wrapping on an IronPythonConsoleControl?
this.PythonRepl.Pad.Control.WordWrap = true;
this.PythonRepl.Host.ConsoleCreated += Host_ConsoleCreated;
}
void Host_ConsoleCreated(object sender, EventArgs e)
{
this.PythonConsole.Console.ConsoleInitialized += Console_ConsoleInitialized;
}
void Console_ConsoleInitialized(object sender, EventArgs e)
{
dynamic env = this.PythonRepl.Console.ScriptScope;
var engine = this.PythonRepl.Console.ScriptScope.Engine;
// here you can add objects, methods and namespaces that you want to
// access from the REPL
// ...
}
You can only access the Console
property safely after the ConsoleInitialized
event has fired, and you can only subscribe to that event after ConsoleCreated
event of the console host has fired, hence this complicated subscription.
Python environment setup
After you have the access to ScriptScope
, the code to add your custom bindings to REPL environment would look pretty simple:
// access the domain model from REPL
env.model = this.domainModel;
// create a shortcut to certain domain model state
env.complicated_situation = new Action(
() =>
{
this.domainModel.OpenDocument(@"C:\\Bacon\\pancakes.mp3");
var session = this.domainModel.WebService.Login(
"J.C. Denton", "bloodshot");
this.domainModel.WebService.ProcessDocument(session,
this.domainModel.CurrentDocument);
// etc ...
});
However, to access your classes and objects, you first need to make the respective namespaces available in the scripting environment. I didn’t bother to investigate how this should be done properly, so I just run some import
s in the environment:
// import namespaces
engine.Execute("import clr", env);
foreach (var a in new[]
{
"Acme.TodoList.Model",
"Acme.TodoList.Mocks",
"Acme.Tools.Tetris",
// etc ...
})
// assume namespace and assembly name are the same
engine.Execute(String.Format("clr.AddReference('{0}')\nimport {0}", a), env);
engine.Execute("from Acme.TodoList import *", env);
Now, you can call methods, inspect and modify properties of objects of your domain models and the UI objects as well. It takes time to set up the Python environment for your particular application, but in return you get the ability to iterate much faster.
comments powered by Disqus