From 0c8fa75a47feccb641df87e3c8bb41334bd55513 Mon Sep 17 00:00:00 2001 From: Sipke Schoorstra Date: Sun, 19 Nov 2023 21:13:19 +0100 Subject: [PATCH] Replace IronPython implementation with Python.NET #4164 --- .../Elsa.WorkflowServer.Web/Program.cs | 12 ++- .../Elsa.WorkflowServer.Web/appsettings.json | 9 +++ src/modules/Elsa.Python/Elsa.Python.csproj | 10 +-- .../Extensions/ModuleExtensions.cs | 9 --- .../Elsa.Python/Features/PythonFeature.cs | 21 ++++-- .../Elsa.Python/Handlers/AddInputAccessors.cs | 4 +- .../Handlers/ConfigurePythonFromOptions.cs | 4 +- .../GenerateWorkflowVariableAccessors.cs | 4 +- .../PythonGlobalInterpreterManager.cs | 42 +++++++++++ .../Models/ExecutionContextProxy.cs | 8 +- src/modules/Elsa.Python/Models/InputProxy.cs | 4 +- .../Elsa.Python/Models/OutcomeProxy.cs | 2 +- src/modules/Elsa.Python/Models/OutputProxy.cs | 6 +- .../Notifications/EvaluatingPython.cs | 7 +- .../Elsa.Python/Options/PythonOptions.cs | 15 +++- .../Services/IronPythonEvaluator.cs | 49 ------------- .../Services/PythonNetPythonEvaluator.cs | 73 +++++++++++++++++++ 17 files changed, 180 insertions(+), 99 deletions(-) create mode 100644 src/modules/Elsa.Python/HostedServices/PythonGlobalInterpreterManager.cs delete mode 100644 src/modules/Elsa.Python/Services/IronPythonEvaluator.cs create mode 100644 src/modules/Elsa.Python/Services/PythonNetPythonEvaluator.cs diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index 917ceb429..e2b0ba7df 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -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) diff --git a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json index edcfc36f2..6597915a8 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/appsettings.json +++ b/src/bundles/Elsa.WorkflowServer.Web/appsettings.json @@ -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')" + ] + } } } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Elsa.Python.csproj b/src/modules/Elsa.Python/Elsa.Python.csproj index d0d1c7175..8e0a0b0ae 100644 --- a/src/modules/Elsa.Python/Elsa.Python.csproj +++ b/src/modules/Elsa.Python/Elsa.Python.csproj @@ -1,7 +1,7 @@ - - + + net6.0;net7.0 @@ -12,12 +12,12 @@ - - + + - + diff --git a/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs index cf82cec11..4f7c61794 100644 --- a/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs @@ -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; } - - /// - /// Setup the feature. - /// - public static IModule UsePython(this IModule module, Action configureOptions) - { - return module.UsePython(python => python.PythonOptions += configureOptions); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Features/PythonFeature.cs b/src/modules/Elsa.Python/Features/PythonFeature.cs index 29210010c..cdbe5ab8b 100644 --- a/src/modules/Elsa.Python/Features/PythonFeature.cs +++ b/src/modules/Elsa.Python/Features/PythonFeature.cs @@ -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) { } - + /// /// Configures the . /// public Action PythonOptions { get; set; } = _ => { }; - + + /// + public override void ConfigureHostedServices() + { + Module.ConfigureHostedService(); + } + /// public override void Apply() { Services.Configure(PythonOptions); - - // C# services. + + // Python services. Services - .AddSingleton() + .AddSingleton() .AddExpressionDescriptorProvider() ; // Handlers. Services.AddNotificationHandlersFrom(); - + // Activities. Module.AddActivitiesFrom(); } diff --git a/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs b/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs index 62b544c07..b69f9f18e 100644 --- a/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs +++ b/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs @@ -14,9 +14,9 @@ public class AddInputAccessors : INotificationHandler /// 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; } } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Handlers/ConfigurePythonFromOptions.cs b/src/modules/Elsa.Python/Handlers/ConfigurePythonFromOptions.cs index 4dfcabb6b..dcbfaea70 100644 --- a/src/modules/Elsa.Python/Handlers/ConfigurePythonFromOptions.cs +++ b/src/modules/Elsa.Python/Handlers/ConfigurePythonFromOptions.cs @@ -25,8 +25,8 @@ public class ConfigurePythonFromOptions : INotificationHandler /// 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; } diff --git a/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs b/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs index 9d4f5cacb..4301a7814 100644 --- a/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs +++ b/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs @@ -31,14 +31,14 @@ public class GenerateWorkflowVariableAccessors : INotificationHandler +/// Initializes the Python engine. +/// +public class PythonGlobalInterpreterManager : IHostedService +{ + private readonly IOptions _options; + private IntPtr _mainThreadState; + + /// + /// Initializes a new instance of the class. + /// + public PythonGlobalInterpreterManager(IOptions options) + { + _options = options; + } + + /// + 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; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + PythonEngine.EndAllowThreads(_mainThreadState); + PythonEngine.Shutdown(); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Models/ExecutionContextProxy.cs b/src/modules/Elsa.Python/Models/ExecutionContextProxy.cs index f36360222..eedea4b66 100644 --- a/src/modules/Elsa.Python/Models/ExecutionContextProxy.cs +++ b/src/modules/Elsa.Python/Models/ExecutionContextProxy.cs @@ -27,22 +27,22 @@ public partial class ExecutionContextProxy /// /// Gets the value of the specified variable. /// - public object? get_variable(Type type, string name) => ExpressionExecutionContext.GetVariableInScope(name).ConvertTo(type); + public object? GetVariable(string name) => ExpressionExecutionContext.GetVariableInScope(name).ConvertTo(); /// /// Sets the value of the specified variable. /// - public void set_variable(string name, object? value) => ExpressionExecutionContext.SetVariable(name, value); + public void SetVariable(string name, object? value) => ExpressionExecutionContext.SetVariable(name, value); /// /// Gets the workflow instance ID. /// - public string workflow_instance_id => ExpressionExecutionContext.GetWorkflowExecutionContext().Id; + public string WorkflowInstanceId => ExpressionExecutionContext.GetWorkflowExecutionContext().Id; /// /// Gets or sets the correlation ID. /// - public string? correlation_id + public string? CorrelationId { get => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId; set => ExpressionExecutionContext.GetWorkflowExecutionContext().CorrelationId = value; diff --git a/src/modules/Elsa.Python/Models/InputProxy.cs b/src/modules/Elsa.Python/Models/InputProxy.cs index 6f51a9cfb..4a6f58ce6 100644 --- a/src/modules/Elsa.Python/Models/InputProxy.cs +++ b/src/modules/Elsa.Python/Models/InputProxy.cs @@ -26,10 +26,10 @@ public class InputProxy /// /// Gets the value of the specified input. /// - public object? get(string name) => Context.GetInput(name); + public object? Get(string name) => Context.GetInput(name); /// /// Gets the value of the specified input. /// - public object? get(Type type, string name) => Context.GetInput(name).ConvertTo(type); + public object? Get(Type type, string name) => Context.GetInput(name).ConvertTo(type); } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Models/OutcomeProxy.cs b/src/modules/Elsa.Python/Models/OutcomeProxy.cs index 3f7859c0b..77c1b0e2e 100644 --- a/src/modules/Elsa.Python/Models/OutcomeProxy.cs +++ b/src/modules/Elsa.Python/Models/OutcomeProxy.cs @@ -27,7 +27,7 @@ public class OutcomeProxy /// Sets the outcome of the current activity. /// /// The names of the outcomes. - public void set(params string[] outcomeNames) + public void Set(params string[] outcomeNames) { ExpressionExecutionContext.TransientProperties[OutcomePropertiesKey] = outcomeNames; } diff --git a/src/modules/Elsa.Python/Models/OutputProxy.cs b/src/modules/Elsa.Python/Models/OutputProxy.cs index dcc086552..c0b73544e 100644 --- a/src/modules/Elsa.Python/Models/OutputProxy.cs +++ b/src/modules/Elsa.Python/Models/OutputProxy.cs @@ -29,7 +29,7 @@ public class OutputProxy /// The ID or name of the activity that produced the output. /// The name of the output. /// The value of the output. - public object? get(string activityIdOrName, string? outputName = default) => Context.GetOutput(activityIdOrName, outputName); + public object? Get(string activityIdOrName, string? outputName = default) => Context.GetOutput(activityIdOrName, outputName); /// /// Gets the value of the specified output. @@ -38,10 +38,10 @@ public class OutputProxy /// The ID or name of the activity that produced the output. /// The name of the output. /// The value of the output. - 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); /// /// Gets the result of the last activity that executed. /// - public object? last_result => Context.GetLastResult(); + public object? LastResult => Context.GetLastResult(); } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Notifications/EvaluatingPython.cs b/src/modules/Elsa.Python/Notifications/EvaluatingPython.cs index 3dcb4adcd..da9804b2a 100644 --- a/src/modules/Elsa.Python/Notifications/EvaluatingPython.cs +++ b/src/modules/Elsa.Python/Notifications/EvaluatingPython.cs @@ -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; /// /// This notification is published every time a Python expression is about to be evaluated, giving subscribers a chance to modify the Python engine. /// -public record EvaluatingPython(ScriptEngine Engine, ScriptScope ScriptScope, ExpressionExecutionContext Context) : INotification +public record EvaluatingPython(PyModule Scope, ExpressionExecutionContext Context) : INotification { /// /// Appends a script to the Python engine. @@ -27,7 +27,6 @@ public record EvaluatingPython(ScriptEngine Engine, ScriptScope ScriptScope, Exp /// The script to append. public void AppendScript(string script) { - var engine = Engine; - engine.Execute(script, ScriptScope); + Scope.Exec(script); } } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Options/PythonOptions.cs b/src/modules/Elsa.Python/Options/PythonOptions.cs index 1d5d79750..cbd7c9255 100644 --- a/src/modules/Elsa.Python/Options/PythonOptions.cs +++ b/src/modules/Elsa.Python/Options/PythonOptions.cs @@ -1,13 +1,20 @@ using System.Text; -using Microsoft.Scripting.Hosting; +using JetBrains.Annotations; +using Python.Runtime; namespace Elsa.Python.Options; /// /// Options for the Python expression evaluator. /// +[PublicAPI] public class PythonOptions { + /// + /// 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. + /// + public string? PythonDllPath { get; set; } + /// /// Gets or sets the Python script files to load. /// @@ -16,7 +23,7 @@ public class PythonOptions /// /// Gets or sets a list of callbacks that are invoked when the Python engine is being configured. /// - public ICollection> ScriptScopes { get; } = new List>(); + public ICollection> Scopes { get; } = new List>(); /// /// Appends a script to the Python engine. @@ -41,8 +48,8 @@ public class PythonOptions /// /// Registers a callback that is invoked when the Python engine is being configured. /// - public void ConfigureScriptScope(Action configure) + public void ConfigureScriptScope(Action configure) { - ScriptScopes.Add(configure); + Scopes.Add(configure); } } \ No newline at end of file diff --git a/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs b/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs deleted file mode 100644 index 81996bd1d..000000000 --- a/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs +++ /dev/null @@ -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; - -/// -/// Evaluates Python expressions using IronPython. -/// -public class IronPythonEvaluator : IPythonEvaluator -{ - private readonly INotificationSender _notificationSender; - - /// - /// Initializes a new instance of the class. - /// - public IronPythonEvaluator(INotificationSender notificationSender) - { - _notificationSender = notificationSender; - } - - /// - public async Task 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); - } -} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Services/PythonNetPythonEvaluator.cs b/src/modules/Elsa.Python/Services/PythonNetPythonEvaluator.cs new file mode 100644 index 000000000..22dab0883 --- /dev/null +++ b/src/modules/Elsa.Python/Services/PythonNetPythonEvaluator.cs @@ -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; + +/// +/// Evaluates Python expressions using IronPython. +/// +public class PythonNetPythonEvaluator : IPythonEvaluator +{ + private const string ReturnVarName = "elsa_python_result_variable_name"; + private readonly INotificationSender _notificationSender; + + /// + /// Initializes a new instance of the class. + /// + public PythonNetPythonEvaluator(INotificationSender notificationSender) + { + _notificationSender = notificationSender; + } + + /// + public async Task 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(ReturnVarName); + return result.ConvertTo(returnType); + } + + /// + /// Wraps the user script in a function called execute_script() and returns the result of that function. + /// + 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(); + } +} \ No newline at end of file