Previously I wrote about the basics of setting up and working with IronPython. In that post, I briefly talked about why one would want to use it in their .NET applications. I stated that the biggest reason to use IronPython would be to add dynamic business logic into your .NET application. However, my first post did not attempt to illustrate that point and instead was just a post where I played around with IronPython and documented the event. So in this sequel post, I will give a contrived example of where IronPython is best suited for use and that is injecting dynamically typed sections of code among the statically typed code. The full source code is available for download at the bottom of this post.
Background
Hypothetical Scenario: You work at a software company called Questionnaires R Us. Your flagship product EasyQuestions is a highly regarded desktop software application written using the .NET Framework which asks users questions and in turn logs the Question and Answer pair to a text file (I told you it was contrived example, didn’t I?) You currently have three clients all of which have slightly different needs. Here’s a listing of the clients and their requirements:
Client 1: The Trendy Tavern is a bar located in a very hip place of downtown and is only interested in people age 35 or younger. So they want their first question to be what a person’s age is and if it is over 35 or under 21 (legal drinking age in the US), then they want to immediately complete the survey and stop asking further questions.
Client 2: Joe’s Box Producers is a company which manufacturers cardboard boxes of all shapes and sizes for shipping and storage needs. Only problem is that their eccentric owner Joe hates the letter A and demands the that this forsaken letter be removed from all answers. Shhh… Don’t tell his wife Alice.
Client 3: Always On Time Delivery Service is a company which prides themselves on the timeliness of their delivery service. This survey is no exception so they demand a timestamp on every answer that the user provides.
Possible Solutions
So as a Software Developer on EasyQuestions how do you meet the needs of all your customers? After gathering and analyzing the requirements you find that all but a single component of your software is common to all customers. The variable part of the application is what questions are asked and how the application responds to the answers. How do you go about solving this?
Idea 1: Keep separate codebases of your software for each customer.
Not Quite… You are going to be carrying around loads of duplicate code for minor changes in requirements. Worst yet, you are going to be repeating yourself…
Idea 2: Split up the project into DLLs where all the types and user controls can be re-used. Leaving you only a small EXE that needs to be written per customer.
This approach is okay, but again we are still making a separate executable/ dedicated project for each customer. It would be nice to have it all down to one compilation, but if the needs of your customers may radically evolve and diverge further down the line then this may be an acceptable point to be at. Another bonus gained from this approach is that you don’t have to leave the world of type safety within the .NET Framework to do it. However for our pretend scenario this approach is not our best option.
Idea 3: Create a configuration file that can be loaded into the application at runtime to address the particular needs of each customer.
This is great and will work if it is only parameters which vary between customers. When business logic varies per application then this approach will not work on it’s own… Based on our imaginary scenario alone – we would need a configuration setting which would inspect the age of the user supplied via a response to a question that is unique to a single customer and then decided whether to continue the survey or not… Good luck writing an XML configuration for that…
Idea 4: Inject dynamically typed code into your application to address the areas of varying business logic.
Bingo was his name-o! Using this approach you inject runtime evaluated scripts into the areas of the application code where the business logic varies. Now your one software project can be configured with any special business logic it requires per customer requirement. One compilation of your software can service all three of your customers!
Writing the Application
So this project is made up of the following components:
- PythonNet class
- SurveyLogger class
- Question Form
- Python Scripts
- XML Configuration File
- Main Form
First thing is to recall the PythonNet
class from the first post. If you didn’t read that post, then that’s okay. Just remember that the class has two fields. The first fields is pyEngine
which is a instance of the PythonEngine
class which allows you to create IronPython script source, modules, scopes, etc. The second field is pyScope
which is an instance of the PythonScope
class which is a container that holds the variables, functions, and classes. Additionally, the two main methods we care about in this example are:
public void AddToScope(string name, object obj) { pyScope.SetVariable(name, obj); } // Adding this for second demo application public void ExecutePythonFile(string pythonFile) { pyEngine.ExecuteFile(pythonFile, pyScope); }
AddToScope
allows us to add an object from our C# code into the scope of our IronPython script, we also give it the name that our IronPython script will reference it by. ExecutePythonFile
takes in a path to a *.py file and executes that file against the scope held within our PythonNet
instance.
Next we define the SurveyLogger
class which logs the user information, questions and the answers given by the participant.
public class SurveyLogger { public SurveyLogger(string firstName, string lastName, string email) { string header = string.Format("First Name: {0}", firstName) + Environment.NewLine; header += string.Format("Last Name: {0}", lastName) + Environment.NewLine; header += string.Format("Email Address: {0}", email) + Environment.NewLine; WriteToLogFile(header); } public void WriteQuestionAnswerPair(string question, string answer) { string questionAnswer = string.Format("Question: {0}", question) + Environment.NewLine; questionAnswer += string.Format("Answer: {0}", answer) + Environment.NewLine; WriteToLogFile(questionAnswer); } private void WriteToLogFile(string text) { using (var writer = new StreamWriter("Survey.txt", true)) { writer.WriteLine(text); } } }
The QuestionForm
is a simple WinForm which displays a question to the user and accepts a response.
Here is the code to the QuestionForm
:
public partial class QuestionForm : Form { public string Answer { get; set; } public QuestionForm(string question) { InitializeComponent(); this.questionTextBox.Text = question; this.answerTextBox.Select(); } private void submitButton_Click(object sender, EventArgs e) { if (!string.IsNullOrEmpty(answerTextBox.Text)) { this.Answer = answerTextBox.Text; this.Close(); } else { MessageBox.Show("Please type in a response before continuing...", "No Answer Given", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } } }
So now that leaves us with scripting out the varying business logic for each customer. I will just list two of them here. Again the full source code is available for download at the bottom of this post.
For the Trendy Tavern , the IronPython script will look something like this:
#imports import clr import sys clr.AddReference('IronPythonDemo2') from IronPythonDemo2 import QuestionForm from IronPythonDemo2 import SurveyLogger #functions def questions(): logger = SurveyLogger(firstName, lastName, email) question1 = "How old are you?" questionForm1 = QuestionForm(question1) questionForm1.ShowDialog() logger.WriteQuestionAnswerPair(question1, questionForm1.Answer) try: if int(questionForm1.Answer) > 35 or int(questionForm1.Answer) < 21: return except: sys.exit("Invalid Input on Age Question") question2 = "What is your favorite beer?" questionForm2 = QuestionForm(question2) questionForm2.ShowDialog() logger.WriteQuestionAnswerPair(question2, questionForm2.Answer) question3 = "Do you like wearing plaid shirts?" questionForm3 = QuestionForm(question3) questionForm3.ShowDialog() logger.WriteQuestionAnswerPair(question3, questionForm3.Answer) #call our function questions()
For Always On Time Delivery Service , the IronPython script will look something like this:
#imports import clr clr.AddReference('IronPythonDemo2') from IronPythonDemo2 import QuestionForm from IronPythonDemo2 import SurveyLogger from System.Net.Sockets import TcpClient from System.IO import StreamReader from System.Globalization import * from System import DateTime #functions def questions(): logger = SurveyLogger(firstName, lastName, email) question1 = "How often do you do business with us?" questionForm1 = QuestionForm(question1) questionForm1.ShowDialog() answer1 = questionForm1.Answer + " - Answered at " + getNistTimeString() logger.WriteQuestionAnswerPair(question1, answer1) question2 = "Has there ever been a time when you were disappointed by our service, if so please explain?" questionForm2 = QuestionForm(question2) questionForm2.ShowDialog() answer2 = questionForm2.Answer + " - Answered at " + getNistTimeString() logger.WriteQuestionAnswerPair(question2, answer2) question3 = "Would you like to see earlier delivery times?" questionForm3 = QuestionForm(question3) questionForm3.ShowDialog() answer3 = questionForm3.Answer + " - Answered at " + getNistTimeString() logger.WriteQuestionAnswerPair(question3, answer3) def getNistTimeString(): client = TcpClient("time.nist.gov", 13) with StreamReader(client.GetStream()) as streamReader: response = streamReader.ReadToEnd() utcDateTimeString = response.Substring(7, 17) localDateTime = DateTime.ParseExact(utcDateTimeString, "yy-MM-dd hh:mm:ss", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal).ToString() return localDateTime #call our function questions()
Now lets put this all together. In our MainForm
code we load which ever Python file we want to use from a simple XML file called EasyQuestionsConfig.xml. Here is a sample where we load the script for Always On Time Delivery Service :
<?xml version="1.0" encoding="utf-8" ?> <ScriptFile>timely_delivery_company.py</ScriptFile>
All that is left to do is to add a few lines of code to add our C# objects into the Python script (namely – first name, last name and email) and then execute the Python Script file based upon our configuration. The code is listed below:
public partial class MainForm : Form { private PythonNet pythonNet; public MainForm() { InitializeComponent(); pythonNet = new PythonNet(); } private void beginQuestionnaireButton_Click(object sender, EventArgs e) { string baseDirectory = Path.Combine( Path.GetDirectoryName( Assembly.GetExecutingAssembly().Location), "Python Scripts"); string myPythonFile = ReadPythonFileFromConfig(); string pythonFilePath = Path.Combine(baseDirectory, myPythonFile); if (!File.Exists(pythonFilePath)) { MessageBox.Show(string.Format("File Not Found: {0}", myPythonFile)); return; } if (string.IsNullOrWhiteSpace(firstNameTextBox.Text) || string.IsNullOrWhiteSpace(lastNameTextBox.Text) || string.IsNullOrWhiteSpace(emailTextBox.Text)) { MessageBox.Show( "Please complete all fields before continuing.", "Fields Left Blank", MessageBoxButtons.OK, MessageBoxIcon.Exclamation ); return; } try { pythonNet.AddToScope("firstName", firstNameTextBox.Text.Trim()); pythonNet.AddToScope("lastName", lastNameTextBox.Text.Trim()); pythonNet.AddToScope("email", emailTextBox.Text.Trim()); pythonNet.ExecutePythonFile(pythonFilePath); MessageBox.Show("Thanks for participating in our survey!", "Thank you!", MessageBoxButtons.OK, MessageBoxIcon.Information); } catch (Exception ex) { MessageBox.Show(ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Exclamation); } } private string ReadPythonFileFromConfig() { XDocument doc = XDocument.Load("EasyQuestionsConfig.xml"); return doc.Elements().Where(x => x.Name.LocalName == "ScriptFile") .First().Value; } }
Screenshot of the MainForm
:
You can easily switch between different customers just by switching out the value of the Script element in EasyQuestionsConfig.xml to a different Python file that is located inside the ‘Python Scripts’ folder found under IronPythonDemo2.exe. This is just a sample application and is obviously relaxed with the error handling. I tried to keep everything pretty sparse in order to not distract attention away from the main goal which was showcasing IronPython in an example where it really shines.
Source code is available for download here.