Replace IronPython implementation with Python.NET

#4164
This commit is contained in:
Sipke Schoorstra 2023-11-19 21:13:19 +01:00
parent c4e812c6ec
commit 0c8fa75a47
17 changed files with 180 additions and 99 deletions

View file

@ -162,10 +162,14 @@ services
engine.Execute("function sayHelloWorld() { return greet('World'); }");
});
})
.UsePython(options =>
.UsePython(python =>
{
options.AddScript("def greet(name): return f\"Hello {name}!\";");
options.AddScript("def say_hello_world(): return greet(\"World\");");
python.PythonOptions += options =>
{
// Make sure to configure the path to the python DLL. E.g. /opt/homebrew/Cellar/python@3.11/3.11.6_1/Frameworks/Python.framework/Versions/3.11/bin/python3.11
// alternatively, you can set the PYTHONNET_PYDLL environment variable.
configuration.GetSection("Scripting:Python").Bind(options);
};
})
.UseLiquid(liquid => liquid.FluidOptions = options => options.Encoder = HtmlEncoder.Default)
.UseHttp(http =>
@ -192,7 +196,7 @@ services
{
elsa.UseQuartz(quartz => { quartz.UseSqlite(sqliteConnectionString); });
}
elsa.UseMassTransit(massTransit =>
{
if (useMassTransitAzureServiceBus)

View file

@ -99,5 +99,14 @@
"SweepInterval": "00:00:10:00",
"BatchSize": 1000
}
},
"Scripting": {
"Python": {
"PythonDllPath": "",
"Scripts": [
"def greet(name): return f'Hello {name}!'",
"def say_hello_world(): return greet('World')"
]
}
}
}

View file

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<Import Project="..\..\..\common.props" />
<Import Project="..\..\..\configureawait.props" />
<Import Project="..\..\..\common.props"/>
<Import Project="..\..\..\configureawait.props"/>
<PropertyGroup>
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
@ -12,12 +12,12 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj" />
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj" />
<ProjectReference Include="..\Elsa.Expressions\Elsa.Expressions.csproj"/>
<ProjectReference Include="..\Elsa.Workflows.Management\Elsa.Workflows.Management.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="IronPython" Version="3.4.1" />
<PackageReference Include="pythonnet" Version="3.0.3"/>
</ItemGroup>
</Project>

View file

@ -1,6 +1,5 @@
using Elsa.Features.Services;
using Elsa.Python.Features;
using Elsa.Python.Options;
// ReSharper disable once CheckNamespace
namespace Elsa.Extensions;
@ -18,12 +17,4 @@ public static class ModuleExtensions
module.Configure(configure);
return module;
}
/// <summary>
/// Setup the <see cref="PythonFeature"/> feature.
/// </summary>
public static IModule UsePython(this IModule module, Action<PythonOptions> configureOptions)
{
return module.UsePython(python => python.PythonOptions += configureOptions);
}
}

View file

@ -1,12 +1,11 @@
using Elsa.Common.Features;
using Elsa.Expressions.Contracts;
using Elsa.Expressions.Features;
using Elsa.Extensions;
using Elsa.Features.Abstractions;
using Elsa.Features.Attributes;
using Elsa.Features.Services;
using Elsa.Python.Contracts;
using Elsa.Python.Expressions;
using Elsa.Python.HostedServices;
using Elsa.Python.Options;
using Elsa.Python.Providers;
using Elsa.Python.Services;
@ -25,26 +24,32 @@ public class PythonFeature : FeatureBase
public PythonFeature(IModule module) : base(module)
{
}
/// <summary>
/// Configures the <see cref="Options.PythonOptions"/>.
/// </summary>
public Action<PythonOptions> PythonOptions { get; set; } = _ => { };
/// <inheritdoc />
public override void ConfigureHostedServices()
{
Module.ConfigureHostedService<PythonGlobalInterpreterManager>();
}
/// <inheritdoc />
public override void Apply()
{
Services.Configure(PythonOptions);
// C# services.
// Python services.
Services
.AddSingleton<IPythonEvaluator, IronPythonEvaluator>()
.AddSingleton<IPythonEvaluator, PythonNetPythonEvaluator>()
.AddExpressionDescriptorProvider<PythonExpressionDescriptorProvider>()
;
// Handlers.
Services.AddNotificationHandlersFrom<PythonFeature>();
// Activities.
Module.AddActivitiesFrom<PythonFeature>();
}

View file

@ -14,9 +14,9 @@ public class AddInputAccessors : INotificationHandler<EvaluatingPython>
/// <inheritdoc />
public Task HandleAsync(EvaluatingPython notification, CancellationToken cancellationToken)
{
var scope = notification.ScriptScope;
var scope = notification.Scope;
var inputProxy = new InputProxy(notification.Context);
scope.SetVariable("input", inputProxy);
scope.Set("input", inputProxy);
return Task.CompletedTask;
}
}

View file

@ -25,8 +25,8 @@ public class ConfigurePythonFromOptions : INotificationHandler<EvaluatingPython>
/// <inheritdoc />
public Task HandleAsync(EvaluatingPython notification, CancellationToken cancellationToken)
{
foreach (var script in _options.Scripts)
notification.AppendScript(script);
foreach (var action in _options.Scopes) action(notification.Scope);
foreach (var script in _options.Scripts) notification.AppendScript(script);
return Task.CompletedTask;
}

View file

@ -31,14 +31,14 @@ public class GenerateWorkflowVariableAccessors : INotificationHandler<Evaluating
var friendlyTypeName = variableType.GetFriendlyTypeName(Brackets.Square);
sb.AppendLine($" @property");
sb.AppendLine($" def {variableName}(self):");
sb.AppendLine($" return self.execution_context.GetVariable({friendlyTypeName}, '{variableName}')");
sb.AppendLine($" return self.execution_context.GetVariable[{friendlyTypeName}]('{variableName}')");
sb.AppendLine($" @{variableName}.setter");
sb.AppendLine($" def {variableName}(self, value):");
sb.AppendLine($" self.execution_context.SetVariable('{variableName}', value)");
}
sb.AppendLine();
sb.AppendLine("variable = WorkflowVariablesProxy(execution_context);");
sb.AppendLine("variables = WorkflowVariablesProxy(execution_context);");
notification.AppendScript(sb.ToString());

View file

@ -0,0 +1,42 @@
using Elsa.Python.Options;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Python.Runtime;
namespace Elsa.Python.HostedServices;
/// <summary>
/// Initializes the Python engine.
/// </summary>
public class PythonGlobalInterpreterManager : IHostedService
{
private readonly IOptions<PythonOptions> _options;
private IntPtr _mainThreadState;
/// <summary>
/// Initializes a new instance of the <see cref="PythonGlobalInterpreterManager"/> class.
/// </summary>
public PythonGlobalInterpreterManager(IOptions<PythonOptions> options)
{
_options = options;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(_options.Value.PythonDllPath))
Environment.SetEnvironmentVariable("PYTHONNET_PYDLL", _options.Value.PythonDllPath);
PythonEngine.Initialize();
_mainThreadState = PythonEngine.BeginAllowThreads();
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
PythonEngine.EndAllowThreads(_mainThreadState);
PythonEngine.Shutdown();
return Task.CompletedTask;
}
}

View file

@ -27,22 +27,22 @@ public partial class ExecutionContextProxy
/// <summary>
/// Gets the value of the specified variable.
/// </summary>
public object? get_variable(Type type, string name) => ExpressionExecutionContext.GetVariableInScope(name).ConvertTo(type);
public object? GetVariable<T>(string name) => ExpressionExecutionContext.GetVariableInScope(name).ConvertTo<T>();
/// <summary>
/// Sets the value of the specified variable.
/// </summary>
public void set_variable(string name, object? value) => ExpressionExecutionContext.SetVariable(name, value);
public void SetVariable(string name, object? value) => ExpressionExecutionContext.SetVariable(name, value);
/// <summary>
/// Gets the workflow instance ID.
/// </summary>
public string workflow_instance_id => ExpressionExecutionContext.GetWorkflowExecutionContext().Id;
public string WorkflowInstanceId => ExpressionExecutionContext.GetWorkflowExecutionContext().Id;
/// <summary>
/// Gets or sets the correlation ID.
/// </summary>
public string? correlation_id
public string? CorrelationId
{
get => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId;
set => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId = value;

View file

@ -26,10 +26,10 @@ public class InputProxy
/// <summary>
/// Gets the value of the specified input.
/// </summary>
public object? get(string name) => Context.GetInput(name);
public object? Get(string name) => Context.GetInput(name);
/// <summary>
/// Gets the value of the specified input.
/// </summary>
public object? get(Type type, string name) => Context.GetInput(name).ConvertTo(type);
public object? Get(Type type, string name) => Context.GetInput(name).ConvertTo(type);
}

View file

@ -27,7 +27,7 @@ public class OutcomeProxy
/// Sets the outcome of the current activity.
/// </summary>
/// <param name="outcomeNames">The names of the outcomes.</param>
public void set(params string[] outcomeNames)
public void Set(params string[] outcomeNames)
{
ExpressionExecutionContext.TransientProperties[OutcomePropertiesKey] = outcomeNames;
}

View file

@ -29,7 +29,7 @@ public class OutputProxy
/// <param name="activityIdOrName">The ID or name of the activity that produced the output.</param>
/// <param name="outputName">The name of the output.</param>
/// <returns>The value of the output.</returns>
public object? get(string activityIdOrName, string? outputName = default) => Context.GetOutput(activityIdOrName, outputName);
public object? Get(string activityIdOrName, string? outputName = default) => Context.GetOutput(activityIdOrName, outputName);
/// <summary>
/// Gets the value of the specified output.
@ -38,10 +38,10 @@ public class OutputProxy
/// <param name="activityIdOrName">The ID or name of the activity that produced the output.</param>
/// <param name="outputName">The name of the output.</param>
/// <returns>The value of the output.</returns>
public object? get(Type returnType, string activityIdOrName, string? outputName = default) => get(activityIdOrName, outputName).ConvertTo(returnType);
public object? Get(Type returnType, string activityIdOrName, string? outputName = default) => Get(activityIdOrName, outputName).ConvertTo(returnType);
/// <summary>
/// Gets the result of the last activity that executed.
/// </summary>
public object? last_result => Context.GetLastResult();
public object? LastResult => Context.GetLastResult();
}

View file

@ -1,14 +1,14 @@
using System.Text;
using Elsa.Expressions.Models;
using Elsa.Mediator.Contracts;
using Microsoft.Scripting.Hosting;
using Python.Runtime;
namespace Elsa.Python.Notifications;
/// <summary>
/// This notification is published every time a Python expression is about to be evaluated, giving subscribers a chance to modify the Python engine.
/// </summary>
public record EvaluatingPython(ScriptEngine Engine, ScriptScope ScriptScope, ExpressionExecutionContext Context) : INotification
public record EvaluatingPython(PyModule Scope, ExpressionExecutionContext Context) : INotification
{
/// <summary>
/// Appends a script to the Python engine.
@ -27,7 +27,6 @@ public record EvaluatingPython(ScriptEngine Engine, ScriptScope ScriptScope, Exp
/// <param name="script">The script to append.</param>
public void AppendScript(string script)
{
var engine = Engine;
engine.Execute(script, ScriptScope);
Scope.Exec(script);
}
}

View file

@ -1,13 +1,20 @@
using System.Text;
using Microsoft.Scripting.Hosting;
using JetBrains.Annotations;
using Python.Runtime;
namespace Elsa.Python.Options;
/// <summary>
/// Options for the Python expression evaluator.
/// </summary>
[PublicAPI]
public class PythonOptions
{
/// <summary>
/// Gets or sets the path to the Python DLL. Alternatively, you can set the PYTHON_DLL environment variable, which is required if you leave this property empty.
/// </summary>
public string? PythonDllPath { get; set; }
/// <summary>
/// Gets or sets the Python script files to load.
/// </summary>
@ -16,7 +23,7 @@ public class PythonOptions
/// <summary>
/// Gets or sets a list of callbacks that are invoked when the Python engine is being configured.
/// </summary>
public ICollection<Action<ScriptScope>> ScriptScopes { get; } = new List<Action<ScriptScope>>();
public ICollection<Action<PyModule>> Scopes { get; } = new List<Action<PyModule>>();
/// <summary>
/// Appends a script to the Python engine.
@ -41,8 +48,8 @@ public class PythonOptions
/// <summary>
/// Registers a callback that is invoked when the Python engine is being configured.
/// </summary>
public void ConfigureScriptScope(Action<ScriptScope> configure)
public void ConfigureScriptScope(Action<PyModule> configure)
{
ScriptScopes.Add(configure);
Scopes.Add(configure);
}
}

View file

@ -1,49 +0,0 @@
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
using Elsa.Mediator.Contracts;
using Elsa.Python.Contracts;
using Elsa.Python.Models;
using Elsa.Python.Notifications;
namespace Elsa.Python.Services;
/// <summary>
/// Evaluates Python expressions using IronPython.
/// </summary>
public class IronPythonEvaluator : IPythonEvaluator
{
private readonly INotificationSender _notificationSender;
/// <summary>
/// Initializes a new instance of the <see cref="IronPythonEvaluator"/> class.
/// </summary>
public IronPythonEvaluator(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
/// <inheritdoc />
public async Task<object?> EvaluateAsync(string expression, Type returnType, ExpressionExecutionContext context, CancellationToken cancellationToken = default)
{
var engine = IronPython.Hosting.Python.CreateEngine();
var scope = engine.CreateScope();
var notification = new EvaluatingPython(engine, scope, context);
// Add imports.
notification.AppendScript(sb =>
{
sb.AppendLine("import System");
sb.AppendLine("import clr");
});
// Add globals.
scope.SetVariable("execution_context", new ExecutionContextProxy(context));
scope.SetVariable("input", new InputProxy(context));
scope.SetVariable("output", new OutputProxy(context));
scope.SetVariable("outcome", new OutcomeProxy(context));
await _notificationSender.SendAsync(notification, cancellationToken);
var result = (object?)engine.Execute(expression, scope);
return result.ConvertTo(returnType);
}
}

View file

@ -0,0 +1,73 @@
using System.Text;
using Elsa.Expressions.Helpers;
using Elsa.Expressions.Models;
using Elsa.Mediator.Contracts;
using Elsa.Python.Contracts;
using Elsa.Python.Models;
using Elsa.Python.Notifications;
using Python.Runtime;
namespace Elsa.Python.Services;
/// <summary>
/// Evaluates Python expressions using IronPython.
/// </summary>
public class PythonNetPythonEvaluator : IPythonEvaluator
{
private const string ReturnVarName = "elsa_python_result_variable_name";
private readonly INotificationSender _notificationSender;
/// <summary>
/// Initializes a new instance of the <see cref="PythonNetPythonEvaluator"/> class.
/// </summary>
public PythonNetPythonEvaluator(INotificationSender notificationSender)
{
_notificationSender = notificationSender;
}
/// <inheritdoc />
public async Task<object?> EvaluateAsync(string expression, Type returnType, ExpressionExecutionContext context, CancellationToken cancellationToken = default)
{
using var gil = Py.GIL();
using var scope = Py.CreateScope();
var notification = new EvaluatingPython(scope, context);
scope.Import("System");
// Add globals.
scope.Set("execution_context", new ExecutionContextProxy(context));
scope.Set("input", new InputProxy(context));
scope.Set("output", new OutputProxy(context));
scope.Set("outcome", new OutcomeProxy(context));
await _notificationSender.SendAsync(notification, cancellationToken);
var wrappedScript = WrapInExecuteScriptFunction(expression);
scope.Exec(wrappedScript);
var result = scope.Get<object>(ReturnVarName);
return result.ConvertTo(returnType);
}
/// <summary>
/// Wraps the user script in a function called execute_script() and returns the result of that function.
/// </summary>
private static string WrapInExecuteScriptFunction(string userScript, int indentationLevel = 1)
{
var lines = userScript.Split('\n');
var wrappedScript = new StringBuilder();
var indentation = new string(' ', 4 * indentationLevel);
wrappedScript.AppendLine("def execute_script():");
foreach (var line in lines.Take(lines.Length - 1))
wrappedScript.AppendLine(indentation + line);
var lastLine = lines.LastOrDefault() ?? "";
if (!lastLine.StartsWith("return"))
lastLine = $"return {lastLine}";
wrappedScript.AppendLine(indentation + lastLine);
wrappedScript.AppendLine($"{ReturnVarName} = execute_script()");
return wrappedScript.ToString();
}
}