S17 Creating a scriptable Plug-In

This article describes how to expose plugin functionality to python scripts. It is assumed that the reader already knows how to create plug-in and interface components, and has basic knowledge of how to write python scripts for CoDeSys.

1 Basic Architecture

The scripting functionality is conceptually divided into three different subjects:

Providing the execution environment

This role is fulfilled by the ScriptEngine.plugin.dll. It wraps the IronPython interpreter and integrates it into the CoDeSys environment. The interfaces are defined in the ScriptEngine interface library, in the _3S.CoDeSys.ScriptEngine namespace. The entry point is the _3S.CoDeSys.ScriptEngine.IScriptEngine system instance with the TypeGuid {372346D1-0A33-4cba-9C90-8C9733BD2DE0}. Apart from some utility functions, it provides factories for the _3S.CoDeSys.ScriptEngine.IScriptExecutor instances which are responsible for actually executing the scripts.

Providing functionality for the scripts

This role is fulfilled by the script driver plugins. As of this writing, we deliver 5 script driver plugins with CoDeSys: ScriptDriverSystem, ScriptDriverProjects, ScriptDriverOnline, ScriptDriverDeviceObject and ScriptDriverLibManObject. Those script drivers exploit only public Automation Platform interfaces, both to the ScriptEngine itself, as well as to the plugins that provide the base for their functionality. The ScriptEngine automatically discovers all available ScriptDriver plugins and utilizes them.

Triggering the script execution

The ScriptEngine.plugin.dll provides the ExecuteScriptCommand as well as the --runscript command line parameter as basic means of executing python scripts. However, other CoDeSys plugin can instantiate (and customize) IScriptExecutor instances and use them for their own (possibly internal) purposes.

2 Providing functionality for the Scripts

Script drivers have several possibilities to provide functionality to the scripts.

2.1 Injecting objects into the scriptengine

This is the most common way to provide functionality for scripts. On initialization, the Script Executor iterates over all IScriptDriver instances known to the component manager, and calls their OnDriverLoad() method. The plugin then can inject objects into the scriptengine python module, which is implicitly imported into the main script scope on script start. For example the ScriptDriverSystem plugin includes the following class:

[TypeGuid("{80D9BAC3-6B0C-4cc5-AE3F-732EB8887487}")]
public class ScriptDriverSystem : IScriptDriver
{
 public void OnDriverLoad(IScriptExecutor executor)
 {
     executor.ProvideObjectForScript("system", new SystemImpl(executor));
     executor.ProvideObjectForScript("Severity", executor.Engine.PrepareType(typeof(Severity)));
     executor.ProvideObjectForScript("Guid", executor.Engine.PrepareType(typeof(Guid)));
     executor.ProvideObjectForScript("PromptResult", executor.Engine.PrepareType(typeof(PromptResult)));
     executor.ProvideObjectForScript("MultipleChoiceSelector", executor.Engine.PrepareType(typeof(MultipleChoiceSelector)));
     executor.ProvideObjectForScript("PromptChoiceFilter", executor.Engine.PrepareType(typeof(PromptChoiceFilter)));
 }
} 

The driver injects an instance of the SystemImpl class with the name system, as well as some enums and delegates which are needed for the python scripts to use the SystemImpl instance. The SystemImpl class is a normal .NET class, and it implements the methods and properties described in the _3S.CoDeSys.ScriptEngine.BasicFunctionality.ISystem interface.

Types (classes, enums, delegates, interfaces, ...) passed to the script should be processed by the IScriptEngine.PrepareType() method. Raw System.Type instances are rather useless for python scripts, and PrepareType converts them into an IronPython type. This exposes static members of the type into the scope (especially useful for enum members) and allows python scripts to cast, instantiate or subclass those types.

2.2 Extending script objects

Script driver plugins can allow other plugins to extend their objects with arbitrary functionality. Due to the dynamic nature of python, this works completely at runtime, and can be individualized for single instances. This mechanism is used by the ScriptDriverDeviceObject and ScriptDriverLibManObject which conditionally extend the IScriptProject and IScriptObject instances provided by the ScriptDriverProjects plugin.

2.2.1 Providing extendable objects

To make an object extendable, it has to implement the _3S.CoDeSys.ScriptEngine.IBaseObject<T> generic interface, using its own public interface as parameter for T. For example, the IScriptObject implementation is declared as follows:

 internal sealed class ScriptObject : TreeObject<IScriptObject>, IScriptObject, IBaseObject<IScriptObject> 

All instances of the object have to be wrapped into an IExtendedObject<T> instance by IScriptEngine.CreateExtendedObject() before being passed to the script. The object returned hides the IExtendedObject<T> members BaseObject and Extensions from the python script, and transparently exposes all public members provided by the base object and extensions. For the script, this object behaves similar to an object that combines BaseObject and all Extensions via multiple inheritance.

All the interfaces exposed to the script must return and accept IExtendedObject<T> instead of T itsself as parameters and return values.

2.2.2 Extending extendable objects

The IScriptEngine.CreateExtendedObject() queries the IObjectExtender instances to provide extensions for the base object. Each IObjectExtender provides a list of interfaces it is interested in, and is subsequently queried for each instance of that type. It can freely decide for if and which extension objects it returns. For example, the library manager script driver implements this interface as follows:

[TypeGuid("{5FC2C20A-D17B-4145-BDA2-72AC16BA65AC}")]
public class LibManObjectExtender : IObjectExtender
{
 public IEnumerable<Type> ExtendableTypes
 {
     get { return new Type[] { typeof(IScriptObject) }; }
 }

 public IEnumerable<object> GetExtensions<T>(IExtendedObject<T> extendedObject)
 where T : IBaseObject<T>
 {
     IScriptObject scriptObject = extendedObject.BaseObject as IScriptObject;

     if (IsLibManObject(scriptObject))
         yield return new ScriptLibManObject(scriptObject);
     else
         yield return NoLibManObject.Singleton;
 }
 // ...
} 

The library manager first checks whether this object is a library manager. If yes, it returns a IScriptLibManObject implementation which provides the library manager functionality. In the other case, it simply returns a marker instance which always returns false for the is_libman property.

2.3 Wrapping script execution

The script drivers can register handlers on the IScriptExecutor.Executing and Executed events. This allows the driver to alter the Environment for the the script execution. For example the system script driver temporarily replaces the MessageService to allow the script to interrogate messages via the prompt_answers dictionary.

3 Defining advanced methods

Despite the fact that Python itsself does not allow method overloads, IronPython scripts can consume overloaded C# methods. The interpreter will inspect the parameters at run time and use the best match - or throw an exception if no 'good enough' match is found. Python also implies some implicit conversions. For example, python lists and tuples are coerced to IEnumerable and IEnumerable<T>, with the latter enumerator instance throwing a class cast exception when it arrives at an elements in the sequence which is not convertable to T.

For variable length argument lists, methods defined using the standard params mytype[] paramname syntax from C# is accepted by python. Python also allows optional and named parameters, which C# officially supports only from Version 4.0 and above. But with the help of the [OptionalAttribute] and [DefaultParameterValueAttribute] attributes (from System.Runtime.InteropServices namespace, in system.dll), one can define such methods. Thus, optional parameters can be omitted (from right to left), and parameters after the omitted ones can be passed by name. If we have an interface which defines the following overloads:

void foo(int a, [Optional][DefaultParameterValue(42)] int b, [Optional] int c);
void foo(int a, [Optional][DefaultParameterValue(42)] int b, [Optional] int c, string d); 

Then some example script calls and their outcome look like this:

test.foo(1, 2, 3, '4') # will call the 4-parameter overload with (1, 2, 3, '4')
test.foo(1, 2, 3, None) # will call the 4-parameter overload with (1, 2, 3, null)
test.foo(1, 2, 3) # will call the 3-parameter overload with (1, 2, 3)
test.foo(1, 2) # will call the 3-parameter overload with (1, 2, 0)
test.foo(1) # will call the 3-parameter overload with (1, 42, 0)
test.foo(1, d='5') # will call the 4-parameter overload with (1, 42, 0, '5')
test.foo(1, d='5', b=2) # will call the 4-parameter overload with (1, 2, 0, '5')
test.foo(c=47, a=11) # will call the 3-parameter overload with (11, 42, 47) 

Note that the call test.foo(1, '4') will not work, as parameter order can only be changed by name, not by type.

Warning: Think well about your parameter names. They should be short but meaningfull, as they can be used by the scripts when passing named parameters. Also, changing the name of a parameter in a method which is exposed to python is an incompatible change!

4 Further recommendations

  1. Plugins should not directly reference the IronPyton and Dynamic Language Runtime DLLs from the GAC, as the compatibility between different versions of the DLL in the same execution environment cannot be guaranteed. When the IronPython implementation is updated, this will introduce problems, as interfaces from two different assembly versions are not compatible. (This rule will be weakened for the Dynamic Language Runtime when CoDeSys migrates to .NET 4.0, as the DLR is included in the .NET rumtime assemblies. For this reason, IScriptEngine provides factory methods for python dictionaries, lists and tuples.
  2. ScriptDrivers should try to abstract over the underlying Automation Platform interfaces, and provide a clean and easy way to access the most commonly used functionality. Most script authors don't have (and should not need) deeper knowledge of the Automation Platform inner workings.
  3. The objects and interfaces exposed to python scripts should adhere to the Naming Conventions noted in the Style Guide for Python Code, which differs from the standards for .NET.
  4. Objects passed to the scripts should be exposed as Automation Platform interfaces. This way, other plugins can provide methods that accept those objects as parameters. One example is ScriptDriverOnline which accepts IScriptObject instances (provided by the ScriptDriverProjects plugin) as parameter for its create_online_application() method.
  5. For similar reasons, ScriptDrivers should provide factory methods to allow other plugins to create instances of those objects. One example is the IScriptDriverProjects interface which can be used by other plugins to create IScriptObject and IScriptProject instances, should they need to return them to a script.
  6. When extending objects, it is advised to always provide marker properties like the IScriptLibManObjectMarker.is_libman property shown above. This way, the script authors can query that marker property to check whether the extension is available, instead of having to deal with exceptions when accessing missing members.

5 Code Example

The Code example (see the "Download" tab) contains Visual Studio 2010 Solution with an example Script Driver plugin and the appropriate Interface component. It provides functions for textual access of POUs and resolving an IP address to a CODESYS address as example implementations. There's also a small test script which calls some of the methods.

ExampleScriptDriver.zip

14 KB

14.04.2021