diff --git a/Elsa.sln b/Elsa.sln index 0f9d79d47..97074488c 100644 --- a/Elsa.sln +++ b/Elsa.sln @@ -283,6 +283,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "storage", "storage", "{B818 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.CSharp", "src\modules\Elsa.CSharp\Elsa.CSharp.csproj", "{24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Elsa.Python", "src\modules\Elsa.Python\Elsa.Python.csproj", "{790E94F2-5393-47DF-AC52-D9247F5B243A}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -709,6 +711,10 @@ Global {24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC}.Release|Any CPU.Build.0 = Release|Any CPU + {790E94F2-5393-47DF-AC52-D9247F5B243A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {790E94F2-5393-47DF-AC52-D9247F5B243A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {790E94F2-5393-47DF-AC52-D9247F5B243A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {790E94F2-5393-47DF-AC52-D9247F5B243A}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -839,6 +845,7 @@ Global {B818988E-639C-4E6E-85C1-B231BCAD9DAB} = {5BA4A8FA-F7F4-45B3-AEC8-8886D35AAC79} {732BF088-6AD7-4C4D-9A48-8074253596D4} = {B818988E-639C-4E6E-85C1-B231BCAD9DAB} {24331E82-D7AF-45B1-ACF0-CA6C3B0B77DC} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76} + {790E94F2-5393-47DF-AC52-D9247F5B243A} = {6EF07978-A6D2-40EB-891D-7D70C5F37E76} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {D4B5CEAA-7D70-4FCB-A68E-B03FBE5E0E5E} diff --git a/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj b/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj index 1c119c42a..e60ce26af 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj +++ b/src/bundles/Elsa.WorkflowServer.Web/Elsa.WorkflowServer.Web.csproj @@ -15,6 +15,7 @@ + diff --git a/src/bundles/Elsa.WorkflowServer.Web/Program.cs b/src/bundles/Elsa.WorkflowServer.Web/Program.cs index 0b47f2415..22fe348bb 100644 --- a/src/bundles/Elsa.WorkflowServer.Web/Program.cs +++ b/src/bundles/Elsa.WorkflowServer.Web/Program.cs @@ -151,7 +151,20 @@ services options.AppendScript("string Greet(string name) => $\"Hello {name}!\";"); options.AppendScript("string SayHelloWorld() => Greet(\"World\");"); }) - .UseJavaScript(options => options.AllowClrAccess = true) + .UseJavaScript(options => + { + options.AllowClrAccess = true; + options.ConfigureEngine(engine => + { + engine.Execute("function greet(name) { return `Hello ${name}!`; }"); + engine.Execute("function sayHelloWorld() { return greet('World'); }"); + }); + }) + .UsePython(options => + { + options.AddScript("def greet(name): return f\"Hello {name}!\";"); + options.AddScript("def say_hello_world(): return greet(\"World\");"); + }) .UseLiquid(liquid => liquid.FluidOptions = options => options.Encoder = HtmlEncoder.Default) .UseHttp(http => { diff --git a/src/clients/Elsa.Api.Client/Converters/ExpressionJsonConverter.cs b/src/clients/Elsa.Api.Client/Converters/ExpressionJsonConverter.cs index 80913a8e5..586e8b540 100644 --- a/src/clients/Elsa.Api.Client/Converters/ExpressionJsonConverter.cs +++ b/src/clients/Elsa.Api.Client/Converters/ExpressionJsonConverter.cs @@ -51,6 +51,7 @@ public class ExpressionJsonConverter : JsonConverter "Literal" => typeof(LiteralExpression), "CSharp" => typeof(CSharpExpression), "JavaScript" => typeof(JavaScriptExpression), + "Python" => typeof(PythonExpression), "Liquid" => typeof(LiquidExpression), "Object" => typeof(ObjectExpression), _ => throw new ArgumentOutOfRangeException(nameof(expressionTypeName), expressionTypeName, null) @@ -62,6 +63,7 @@ public class ExpressionJsonConverter : JsonConverter nameof(LiteralExpression) => "Literal", nameof(CSharpExpression) => "CSharp", nameof(JavaScriptExpression) => "JavaScript", + nameof(PythonExpression) => "Python", nameof(LiquidExpression) => "Liquid", nameof(ObjectExpression) => "Object", _ => throw new ArgumentOutOfRangeException(nameof(expressionType), expressionType, null) diff --git a/src/clients/Elsa.Api.Client/Expressions/CSharpExpression.cs b/src/clients/Elsa.Api.Client/Expressions/CSharpExpression.cs index 9a0bac7e8..4ef724b49 100644 --- a/src/clients/Elsa.Api.Client/Expressions/CSharpExpression.cs +++ b/src/clients/Elsa.Api.Client/Expressions/CSharpExpression.cs @@ -4,7 +4,7 @@ using Elsa.Api.Client.Contracts; namespace Elsa.Api.Client.Expressions; /// -/// Represents a c# expression. +/// Represents a C# expression. /// public class CSharpExpression : IExpression { @@ -19,19 +19,19 @@ public class CSharpExpression : IExpression /// /// Initializes a new instance of the class. /// - /// The c# expression. + /// The C# expression. public CSharpExpression(string value) { Value = value; } /// - /// Gets or sets the c# expression. + /// Gets or sets the C# expression. /// public string? Value { get; set; } /// - /// Returns the c# expression. + /// Returns the C# expression. /// public override string ToString() => Value ?? ""; } \ No newline at end of file diff --git a/src/clients/Elsa.Api.Client/Expressions/PythonExpression.cs b/src/clients/Elsa.Api.Client/Expressions/PythonExpression.cs new file mode 100644 index 000000000..d7e44d571 --- /dev/null +++ b/src/clients/Elsa.Api.Client/Expressions/PythonExpression.cs @@ -0,0 +1,37 @@ +using System.Text.Json.Serialization; +using Elsa.Api.Client.Contracts; + +namespace Elsa.Api.Client.Expressions; + +/// +/// Represents a Python expression. +/// +public class PythonExpression : IExpression +{ + /// + /// Initializes a new instance of the class. + /// + [JsonConstructor] + public PythonExpression() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The Python expression. + public PythonExpression(string value) + { + Value = value; + } + + /// + /// Gets or sets the Python expression. + /// + public string? Value { get; set; } + + /// + /// Returns the Python expression. + /// + public override string ToString() => Value ?? ""; +} \ No newline at end of file diff --git a/src/modules/Elsa.CSharp/Activities/RunCSharp/RunCSharp.cs b/src/modules/Elsa.CSharp/Activities/RunCSharp/RunCSharp.cs index c8a5c6e0f..57c1c7124 100644 --- a/src/modules/Elsa.CSharp/Activities/RunCSharp/RunCSharp.cs +++ b/src/modules/Elsa.CSharp/Activities/RunCSharp/RunCSharp.cs @@ -27,12 +27,6 @@ public class RunCSharp : CodeActivity Script = new Input(script); } - /// - /// A list of possible outcomes. Use "SetOutcome(string)" to set the outcome. Use "SetOutcomes(params string[])" to set multiple outcomes. - /// - [Input(Description = "A list of possible outcomes.", UIHint = InputUIHints.DynamicOutcomes)] - public Input> PossibleOutcomes { get; set; } = default!; - /// /// The script to run. /// @@ -42,6 +36,12 @@ public class RunCSharp : CodeActivity OptionsProvider = typeof(RunCSharpOptionsProvider) )] public Input Script { get; set; } = new(""); + + /// + /// A list of possible outcomes. Use "SetOutcome(string)" to set the outcome. Use "SetOutcomes(params string[])" to set multiple outcomes. + /// + [Input(Description = "A list of possible outcomes.", UIHint = InputUIHints.DynamicOutcomes)] + public Input> PossibleOutcomes { get; set; } = default!; /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) @@ -52,7 +52,7 @@ public class RunCSharp : CodeActivity if (string.IsNullOrWhiteSpace(script)) return; - // Get a JavaScript evaluator. + // Get a C# evaluator. var evaluator = context.GetRequiredService(); // Run the script. diff --git a/src/modules/Elsa.CSharp/Extensions/ModuleExtensions.cs b/src/modules/Elsa.CSharp/Extensions/ModuleExtensions.cs index 72480ce8d..b264bbb8f 100644 --- a/src/modules/Elsa.CSharp/Extensions/ModuleExtensions.cs +++ b/src/modules/Elsa.CSharp/Extensions/ModuleExtensions.cs @@ -24,6 +24,6 @@ public static class ModuleExtensions /// public static IModule UseCSharp(this IModule module, Action configureOptions) { - return module.UseCSharp(csharp => csharp.RoslynOptions += configureOptions); + return module.UseCSharp(csharp => csharp.CSharpOptions += configureOptions); } } \ No newline at end of file diff --git a/src/modules/Elsa.CSharp/Features/CSharpFeature.cs b/src/modules/Elsa.CSharp/Features/CSharpFeature.cs index 6bf753627..c2239cc11 100644 --- a/src/modules/Elsa.CSharp/Features/CSharpFeature.cs +++ b/src/modules/Elsa.CSharp/Features/CSharpFeature.cs @@ -27,14 +27,14 @@ public class CSharpFeature : FeatureBase } /// - /// Configures the . + /// Configures the . /// - public Action RoslynOptions { get; set; } = _ => { }; + public Action CSharpOptions { get; set; } = _ => { }; /// public override void Apply() { - Services.Configure(RoslynOptions); + Services.Configure(CSharpOptions); // C# services. Services diff --git a/src/modules/Elsa.CSharp/Handlers/GenerateWorkflowVariableAccessors.cs b/src/modules/Elsa.CSharp/Handlers/GenerateWorkflowVariableAccessors.cs index 0884b9fc0..a02784329 100644 --- a/src/modules/Elsa.CSharp/Handlers/GenerateWorkflowVariableAccessors.cs +++ b/src/modules/Elsa.CSharp/Handlers/GenerateWorkflowVariableAccessors.cs @@ -1,5 +1,6 @@ using System.Text; using Elsa.CSharp.Notifications; +using Elsa.Expressions.Models; using Elsa.Extensions; using Elsa.Mediator.Contracts; using Humanizer; @@ -30,7 +31,7 @@ public class GenerateWorkflowVariableAccessors : INotificationHandler Get<{friendlyTypeName}>(\"{variableName}\");"); @@ -43,29 +44,4 @@ public class GenerateWorkflowVariableAccessors : INotificationHandler - /// Gets a friendly type name for the specified type that is suitable for constructing C# code. - /// - private static string GetFriendlyTypeName(Type type) - { - if (!type.IsGenericType) - return type.FullName!; - - var sb = new StringBuilder(); - sb.Append(type.Namespace); - sb.Append('.'); - sb.Append(type.Name[..type.Name.IndexOf('`')]); - sb.Append('<'); - var genericArgs = type.GetGenericArguments(); - for (var i = 0; i < genericArgs.Length; i++) - { - if (i > 0) - sb.Append(", "); - sb.Append(GetFriendlyTypeName(genericArgs[i])); - } - - sb.Append('>'); - return sb.ToString(); - } } \ No newline at end of file diff --git a/src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs b/src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs index 2a0dff512..9b4ae2283 100644 --- a/src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs +++ b/src/modules/Elsa.Expressions/Extensions/TypeExtensions.cs @@ -1,5 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; +using System.Text; +using Elsa.Expressions.Models; // ReSharper disable once CheckNamespace namespace Elsa.Extensions; @@ -18,7 +20,7 @@ public static class TypeExtensions public static string GetSimpleAssemblyQualifiedName(this Type type) { if (type is null) throw new ArgumentNullException(nameof(type)); - return SimpleAssemblyQualifiedTypeNameCache.GetOrAdd(type, BuildSimplifiedName); + return SimpleAssemblyQualifiedTypeNameCache.GetOrAdd(type, GetSimplifiedName); } /// @@ -77,8 +79,33 @@ public static class TypeExtensions return null; } + + /// + /// Gets a friendly type name for the specified type. + /// + public static string GetFriendlyTypeName(this Type type, Brackets brackets) + { + if (!type.IsGenericType) + return type.FullName!; - private static string BuildSimplifiedName(Type type) + var sb = new StringBuilder(); + sb.Append(type.Namespace); + sb.Append('.'); + sb.Append(type.Name[..type.Name.IndexOf('`')]); + sb.Append(brackets.Open); + var genericArgs = type.GetGenericArguments(); + for (var i = 0; i < genericArgs.Length; i++) + { + if (i > 0) + sb.Append(", "); + sb.Append(GetFriendlyTypeName(genericArgs[i], brackets)); + } + + sb.Append(brackets.Close); + return sb.ToString(); + } + + private static string GetSimplifiedName(Type type) { var assemblyName = type.Assembly.GetName().Name; @@ -90,7 +117,7 @@ public static class TypeExtensions var arity = genericTypeName[backtickIndex..]; var genericArguments = type.GetGenericArguments(); - var simplifiedGenericArguments = genericArguments.Select(BuildSimplifiedName); + var simplifiedGenericArguments = genericArguments.Select(GetSimplifiedName); return $"{typeNameWithoutArity}{arity}[[{string.Join("],[", simplifiedGenericArguments)}]], {assemblyName}"; } diff --git a/src/modules/Elsa.Expressions/Models/Brackets.cs b/src/modules/Elsa.Expressions/Models/Brackets.cs new file mode 100644 index 000000000..8e8b0b5d6 --- /dev/null +++ b/src/modules/Elsa.Expressions/Models/Brackets.cs @@ -0,0 +1,37 @@ +namespace Elsa.Expressions.Models; + +/// +/// Represents a bracket pair. +/// +public class Brackets +{ + /// + /// Gets the opening bracket. + /// + public char Open { get; } + /// + /// Gets the closing bracket. + /// + public char Close { get; } + + /// + /// Initializes a new instance of the class. + /// + /// The opening bracket. + /// The closing bracket. + public Brackets(char open, char close) + { + Open = open; + Close = close; + } + + /// + /// An angle bracket pair. + /// + public static Brackets Angle => new('<', '>'); + + /// + /// A square bracket pair. + /// + public static Brackets Square => new('[', ']'); +} \ No newline at end of file diff --git a/src/modules/Elsa.JavaScript/Activities/RunJavaScript/RunJavaScript.cs b/src/modules/Elsa.JavaScript/Activities/RunJavaScript/RunJavaScript.cs index d0bac606b..50411c506 100644 --- a/src/modules/Elsa.JavaScript/Activities/RunJavaScript/RunJavaScript.cs +++ b/src/modules/Elsa.JavaScript/Activities/RunJavaScript/RunJavaScript.cs @@ -25,12 +25,6 @@ public class RunJavaScript : CodeActivity { Script = new Input(script); } - - /// - /// A list of possible outcomes. Use "setOutcome()" to set the outcome. Use "setOutcomes" to set multiple outcomes. - /// - [Input(Description = "A list of possible outcomes.", UIHint = InputUIHints.DynamicOutcomes)] - public Input> PossibleOutcomes { get; set; } = default!; /// /// The script to run. @@ -41,6 +35,12 @@ public class RunJavaScript : CodeActivity OptionsProvider = typeof(RunJavaScriptOptionsProvider) )] public Input Script { get; set; } = new(""); + + /// + /// A list of possible outcomes. Use "setOutcome()" to set the outcome. Use "setOutcomes" to set multiple outcomes. + /// + [Input(Description = "A list of possible outcomes.", UIHint = InputUIHints.DynamicOutcomes)] + public Input> PossibleOutcomes { get; set; } = default!; /// protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) diff --git a/src/modules/Elsa.Python/Activities/RunPython/RunPython.cs b/src/modules/Elsa.Python/Activities/RunPython/RunPython.cs new file mode 100644 index 000000000..52b211fac --- /dev/null +++ b/src/modules/Elsa.Python/Activities/RunPython/RunPython.cs @@ -0,0 +1,70 @@ +using System.Runtime.CompilerServices; +using Elsa.Extensions; +using Elsa.Python.Contracts; +using Elsa.Python.Models; +using Elsa.Workflows.Core; +using Elsa.Workflows.Core.Attributes; +using Elsa.Workflows.Core.Models; + +// ReSharper disable once CheckNamespace +namespace Elsa.Python.Activities; + +/// +/// Executes Python code. +/// +[Activity("Elsa", "Scripting", "Executes Python code", DisplayName = "Run Python")] +public class RunPython : CodeActivity +{ + /// + public RunPython([CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : base(source, line) + { + } + + /// + public RunPython(string script, [CallerFilePath] string? source = default, [CallerLineNumber] int? line = default) : this(source, line) + { + Script = new Input(script); + } + + /// + /// The script to run. + /// + [Input( + Description = "The script to run.", + UIHint = InputUIHints.CodeEditor, + OptionsProvider = typeof(RunPythonOptionsProvider) + )] + public Input Script { get; set; } = new(""); + + /// + /// A list of possible outcomes. Use "SetOutcome(string)" to set the outcome. Use "SetOutcomes(params string[])" to set multiple outcomes. + /// + [Input(Description = "A list of possible outcomes.", UIHint = InputUIHints.DynamicOutcomes)] + public Input> PossibleOutcomes { get; set; } = default!; + + /// + protected override async ValueTask ExecuteAsync(ActivityExecutionContext context) + { + var script = context.Get(Script); + + // If no script was specified, there's nothing to do. + if (string.IsNullOrWhiteSpace(script)) + return; + + // Get a Python evaluator. + var evaluator = context.GetRequiredService(); + + // Run the script. + var result = await evaluator.EvaluateAsync(script, typeof(object), context.ExpressionExecutionContext, context.CancellationToken); + + // Set the result as output, if any. + if (result is not null) + context.Set(Result, result); + + // Get the outcome or outcomes set by the script, if any. If not set, use "Done". + var outcomes = context.ExpressionExecutionContext.TransientProperties.GetValueOrDefault(OutcomeProxy.OutcomePropertiesKey, () => new[] { "Done" })!; + + // Complete the activity with the outcome. + await context.CompleteActivityWithOutcomesAsync(outcomes); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Activities/RunPython/RunPythonOptionsProvider.cs b/src/modules/Elsa.Python/Activities/RunPython/RunPythonOptionsProvider.cs new file mode 100644 index 000000000..7b2ebe50b --- /dev/null +++ b/src/modules/Elsa.Python/Activities/RunPython/RunPythonOptionsProvider.cs @@ -0,0 +1,22 @@ +using System.Reflection; +using Elsa.Workflows.Core.Contracts; +using Elsa.Workflows.Management.ActivityInputOptions; + +// ReSharper disable once CheckNamespace +namespace Elsa.Python.Activities; + +internal class RunPythonOptionsProvider : IActivityPropertyOptionsProvider +{ + public ValueTask> GetOptionsAsync(PropertyInfo property, CancellationToken cancellationToken = default) + { + var options = new Dictionary + { + ["CodeEditorOptions"] = new CodeEditorOptions + { + Language = "python" + } + }; + + return new(options); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Contracts/IPythonEvaluator.cs b/src/modules/Elsa.Python/Contracts/IPythonEvaluator.cs new file mode 100644 index 000000000..758cc7e12 --- /dev/null +++ b/src/modules/Elsa.Python/Contracts/IPythonEvaluator.cs @@ -0,0 +1,25 @@ +using Elsa.Expressions.Models; +using JetBrains.Annotations; + +namespace Elsa.Python.Contracts; + +/// +/// Evaluates C# expressions. +/// +[PublicAPI] +public interface IPythonEvaluator +{ + /// + /// Evaluates a Python expression. + /// + /// The expression to evaluate. + /// The type of the return value. + /// The context in which the expression is evaluated. + /// An optional cancellation token. + /// The result of the evaluation. + Task EvaluateAsync( + string expression, + Type returnType, + ExpressionExecutionContext context, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Elsa.Python.csproj b/src/modules/Elsa.Python/Elsa.Python.csproj new file mode 100644 index 000000000..d0d1c7175 --- /dev/null +++ b/src/modules/Elsa.Python/Elsa.Python.csproj @@ -0,0 +1,23 @@ + + + + + + + net6.0;net7.0 + + Provides a Python expression provider. + + elsa module expressions scripting python + + + + + + + + + + + + diff --git a/src/modules/Elsa.Python/Expressions/PythonExpression.cs b/src/modules/Elsa.Python/Expressions/PythonExpression.cs new file mode 100644 index 000000000..c07a5c154 --- /dev/null +++ b/src/modules/Elsa.Python/Expressions/PythonExpression.cs @@ -0,0 +1,23 @@ +using Elsa.Expressions.Contracts; + +namespace Elsa.Python.Expressions; + +/// +/// A Python expression. +/// +public class PythonExpression : IExpression +{ + /// + /// Initializes a new instance of the class. + /// + /// The Python expression. + public PythonExpression(string value) + { + Value = value; + } + + /// + /// Gets the Python expression. + /// + public string Value { get; } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Expressions/PythonExpressionBlockReference.cs b/src/modules/Elsa.Python/Expressions/PythonExpressionBlockReference.cs new file mode 100644 index 000000000..a6fc64a3e --- /dev/null +++ b/src/modules/Elsa.Python/Expressions/PythonExpressionBlockReference.cs @@ -0,0 +1,29 @@ +using Elsa.Expressions.Models; + +namespace Elsa.Python.Expressions; + +/// +/// A reference to a Python expression. +/// +public class PythonExpressionBlockReference : MemoryBlockReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The expression to reference. + public PythonExpressionBlockReference(PythonExpression expression) + { + Expression = expression; + } + + /// + /// Gets the referenced expression. + /// + public PythonExpression Expression { get; } + + /// + /// Declares the referenced expression. + /// + /// A memory block holding the expression. + public override MemoryBlock Declare() => new(); +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Expressions/PythonExpressionHandler.cs b/src/modules/Elsa.Python/Expressions/PythonExpressionHandler.cs new file mode 100644 index 000000000..85b6cd30d --- /dev/null +++ b/src/modules/Elsa.Python/Expressions/PythonExpressionHandler.cs @@ -0,0 +1,28 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Python.Contracts; + +namespace Elsa.Python.Expressions; + +/// +/// Evaluates C# expressions. +/// +public class PythonExpressionHandler : IExpressionHandler +{ + private readonly IPythonEvaluator _evaluator; + + /// + /// Initializes a new instance of the class. + /// + public PythonExpressionHandler(IPythonEvaluator evaluator) + { + _evaluator = evaluator; + } + + /// + public async ValueTask EvaluateAsync(IExpression expression, Type returnType, ExpressionExecutionContext context) + { + var pythonExpression = (PythonExpression)expression; + return await _evaluator.EvaluateAsync(pythonExpression.Value, returnType, context); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs b/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs new file mode 100644 index 000000000..cf82cec11 --- /dev/null +++ b/src/modules/Elsa.Python/Extensions/ModuleExtensions.cs @@ -0,0 +1,29 @@ +using Elsa.Features.Services; +using Elsa.Python.Features; +using Elsa.Python.Options; + +// ReSharper disable once CheckNamespace +namespace Elsa.Extensions; + +/// +/// Adds extensions to that installs the feature. +/// +public static class ModuleExtensions +{ + /// + /// Setup the feature. + /// + public static IModule UsePython(this IModule module, Action? configure = default) + { + 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 new file mode 100644 index 000000000..1c269730c --- /dev/null +++ b/src/modules/Elsa.Python/Features/PythonFeature.cs @@ -0,0 +1,52 @@ +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.Options; +using Elsa.Python.Providers; +using Elsa.Python.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Elsa.Python.Features; + +/// +/// Installs Python integration. +/// +[DependsOn(typeof(MediatorFeature))] +[DependsOn(typeof(ExpressionsFeature))] +public class PythonFeature : FeatureBase +{ + /// + public PythonFeature(IModule module) : base(module) + { + } + + /// + /// Configures the . + /// + public Action PythonOptions { get; set; } = _ => { }; + + /// + public override void Apply() + { + Services.Configure(PythonOptions); + + // C# services. + Services + .AddSingleton() + .AddSingleton() + .AddExpressionHandler() + ; + + // Handlers. + Services.AddNotificationHandlersFrom(); + + // Activities. + Module.AddActivitiesFrom(); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/FodyWeavers.xml b/src/modules/Elsa.Python/FodyWeavers.xml new file mode 100644 index 000000000..00e1d9a1c --- /dev/null +++ b/src/modules/Elsa.Python/FodyWeavers.xml @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs b/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs new file mode 100644 index 000000000..62b544c07 --- /dev/null +++ b/src/modules/Elsa.Python/Handlers/AddInputAccessors.cs @@ -0,0 +1,22 @@ +using Elsa.Mediator.Contracts; +using Elsa.Python.Models; +using Elsa.Python.Notifications; +using JetBrains.Annotations; + +namespace Elsa.Python.Handlers; + +/// +/// Adds input accessors to the Python engine. +/// +[UsedImplicitly] +public class AddInputAccessors : INotificationHandler +{ + /// + public Task HandleAsync(EvaluatingPython notification, CancellationToken cancellationToken) + { + var scope = notification.ScriptScope; + var inputProxy = new InputProxy(notification.Context); + scope.SetVariable("input", inputProxy); + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Handlers/ConfigureScriptsFromOptions.cs b/src/modules/Elsa.Python/Handlers/ConfigureScriptsFromOptions.cs new file mode 100644 index 000000000..4dfcabb6b --- /dev/null +++ b/src/modules/Elsa.Python/Handlers/ConfigureScriptsFromOptions.cs @@ -0,0 +1,33 @@ +using Elsa.Mediator.Contracts; +using Elsa.Python.Notifications; +using Elsa.Python.Options; +using JetBrains.Annotations; +using Microsoft.Extensions.Options; + +namespace Elsa.Python.Handlers; + +/// +/// This handler configures the Python engine based on the . +/// +[UsedImplicitly] +public class ConfigurePythonFromOptions : INotificationHandler +{ + private readonly PythonOptions _options; + + /// + /// Initializes a new instance of the class. + /// + public ConfigurePythonFromOptions(IOptions options) + { + _options = options.Value; + } + + /// + public Task HandleAsync(EvaluatingPython notification, CancellationToken cancellationToken) + { + foreach (var script in _options.Scripts) + notification.AppendScript(script); + + return Task.CompletedTask; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs b/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs new file mode 100644 index 000000000..9d4f5cacb --- /dev/null +++ b/src/modules/Elsa.Python/Handlers/GenerateWorkflowVariableAccessors.cs @@ -0,0 +1,47 @@ +using System.Text; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using Elsa.Mediator.Contracts; +using Elsa.Python.Notifications; +using JetBrains.Annotations; + +namespace Elsa.Python.Handlers; + +/// +/// Configures the C# evaluator with methods to access workflow variables. +/// +[UsedImplicitly] +public class GenerateWorkflowVariableAccessors : INotificationHandler +{ + /// + public Task HandleAsync(EvaluatingPython notification, CancellationToken cancellationToken) + { + var expressionExecutionContext = notification.Context; + var variables = expressionExecutionContext.GetVariablesInScope().ToList(); + var sb = new StringBuilder(); + sb.AppendLine("class WorkflowVariablesProxy:"); + sb.AppendLine(" def __init__(self, execution_context):"); + sb.AppendLine(" self.execution_context = execution_context"); + sb.AppendLine(); + + foreach (var variable in variables) + { + var variableName = variable.Name; + var variableType = variable.GetVariableType(); + 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($" @{variableName}.setter"); + sb.AppendLine($" def {variableName}(self, value):"); + sb.AppendLine($" self.execution_context.SetVariable('{variableName}', value)"); + } + + sb.AppendLine(); + sb.AppendLine("variable = WorkflowVariablesProxy(execution_context);"); + + notification.AppendScript(sb.ToString()); + + 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 new file mode 100644 index 000000000..591db80e9 --- /dev/null +++ b/src/modules/Elsa.Python/Models/ExecutionContextProxy.cs @@ -0,0 +1,36 @@ +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Extensions; +using JetBrains.Annotations; + +namespace Elsa.Python.Models; + +/// +/// Provides access to the current execution context. +/// +[UsedImplicitly] +public partial class ExecutionContextProxy +{ + /// + /// Gets the current execution context. + /// + public ExpressionExecutionContext ExpressionExecutionContext { get; } + + /// + /// Initializes a new instance of the class. + /// + public ExecutionContextProxy(ExpressionExecutionContext expressionExecutionContext) + { + ExpressionExecutionContext = expressionExecutionContext; + } + + /// + /// Gets the value of the specified variable. + /// + public object? GetVariable(Type type, string name) => ExpressionExecutionContext.GetVariableInScope(name).ConvertTo(type); + + /// + /// Sets the value of the specified variable. + /// + public void SetVariable(string name, object? value) => ExpressionExecutionContext.SetVariable(name, value); +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Models/InputProxy.cs b/src/modules/Elsa.Python/Models/InputProxy.cs new file mode 100644 index 000000000..6f51a9cfb --- /dev/null +++ b/src/modules/Elsa.Python/Models/InputProxy.cs @@ -0,0 +1,35 @@ +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Extensions; +// ReSharper disable InconsistentNaming + +namespace Elsa.Python.Models; + +/// +/// A wrapper for accessing activity input values from Python. +/// +public class InputProxy +{ + /// + /// Initializes a new instance of the class. + /// + public InputProxy(ExpressionExecutionContext context) + { + Context = context; + } + + /// + /// Gets the expression execution context. + /// + public ExpressionExecutionContext Context { get; set; } + + /// + /// Gets the value of the specified input. + /// + 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); +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Models/OutcomeProxy.cs b/src/modules/Elsa.Python/Models/OutcomeProxy.cs new file mode 100644 index 000000000..3f7859c0b --- /dev/null +++ b/src/modules/Elsa.Python/Models/OutcomeProxy.cs @@ -0,0 +1,34 @@ +using Elsa.Expressions.Models; +// ReSharper disable InconsistentNaming + +namespace Elsa.Python.Models; + +/// +/// Provides access to activity outcomes. +/// +public class OutcomeProxy +{ + /// + /// Gets the key of the outcomes property. + /// + public static readonly object OutcomePropertiesKey = new(); + + /// + /// Initializes a new instance of the class. + /// + public OutcomeProxy(ExpressionExecutionContext expressionExecutionContext) + { + ExpressionExecutionContext = expressionExecutionContext; + } + + private ExpressionExecutionContext ExpressionExecutionContext { get; } + + /// + /// Sets the outcome of the current activity. + /// + /// The names of the outcomes. + public void set(params string[] outcomeNames) + { + ExpressionExecutionContext.TransientProperties[OutcomePropertiesKey] = outcomeNames; + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Models/OutputProxy.cs b/src/modules/Elsa.Python/Models/OutputProxy.cs new file mode 100644 index 000000000..dcc086552 --- /dev/null +++ b/src/modules/Elsa.Python/Models/OutputProxy.cs @@ -0,0 +1,47 @@ +using Elsa.Expressions.Helpers; +using Elsa.Expressions.Models; +using Elsa.Extensions; +// ReSharper disable InconsistentNaming + +namespace Elsa.Python.Models; + +/// +/// Provides access to activity outputs. +/// +public class OutputProxy +{ + /// + /// Initializes a new instance of the class. + /// + public OutputProxy(ExpressionExecutionContext expressionExecutionContext) + { + Context = expressionExecutionContext; + } + + /// + /// Gets the expression execution context. + /// + public ExpressionExecutionContext Context { get; } + + /// + /// Gets the value of the specified output. + /// + /// 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); + + /// + /// Gets the value of the specified output. + /// + /// The type to convert the output value to. + /// 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); + + /// + /// Gets the result of the last activity that executed. + /// + public object? last_result => 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 new file mode 100644 index 000000000..3dcb4adcd --- /dev/null +++ b/src/modules/Elsa.Python/Notifications/EvaluatingPython.cs @@ -0,0 +1,33 @@ +using System.Text; +using Elsa.Expressions.Models; +using Elsa.Mediator.Contracts; +using Microsoft.Scripting.Hosting; + +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 +{ + /// + /// Appends a script to the Python engine. + /// + /// A builder that builds the script to append. + public void AppendScript(Action builder) + { + var sb = new StringBuilder(); + builder(sb); + AppendScript(sb.ToString()); + } + + /// + /// Appends a script to the Python engine. + /// + /// The script to append. + public void AppendScript(string script) + { + var engine = Engine; + engine.Execute(script, ScriptScope); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Options/PythonOptions.cs b/src/modules/Elsa.Python/Options/PythonOptions.cs new file mode 100644 index 000000000..48942d9d7 --- /dev/null +++ b/src/modules/Elsa.Python/Options/PythonOptions.cs @@ -0,0 +1,34 @@ +using System.Text; + +namespace Elsa.Python.Options; + +/// +/// Options for the Python expression evaluator. +/// +public class PythonOptions +{ + /// + /// Gets or sets the Python script files to load. + /// + public ICollection Scripts { get; } = new List(); + + /// + /// Appends a script to the Python engine. + /// + /// A builder that builds the script to append. + public void AddScript(Action builder) + { + var sb = new StringBuilder(); + builder(sb); + AddScript(sb.ToString()); + } + + /// + /// Appends a script to the Python engine. + /// + /// The script to append. + public void AddScript(string script) + { + Scripts.Add(script); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Providers/PythonExpressionSyntaxProvider.cs b/src/modules/Elsa.Python/Providers/PythonExpressionSyntaxProvider.cs new file mode 100644 index 000000000..9f2ed68f4 --- /dev/null +++ b/src/modules/Elsa.Python/Providers/PythonExpressionSyntaxProvider.cs @@ -0,0 +1,36 @@ +using Elsa.Expressions.Contracts; +using Elsa.Expressions.Models; +using Elsa.Python.Expressions; + +namespace Elsa.Python.Providers; + +internal class PythonExpressionSyntaxProvider : IExpressionSyntaxProvider +{ + private const string SyntaxName = "Python"; + + public ValueTask> GetDescriptorsAsync(CancellationToken cancellationToken = default) + { + var javaScript = CreatePythonDescriptor(); + + return ValueTask.FromResult>(new[] { javaScript }); + } + + private ExpressionSyntaxDescriptor CreatePythonDescriptor() => new() + { + Syntax = SyntaxName, + Type = typeof(PythonExpression), + CreateExpression = CreateCSharpExpression, + CreateBlockReference = context => new PythonExpressionBlockReference(context.GetExpression()), + CreateSerializableObject = context => new + { + Type = SyntaxName, + context.GetExpression().Value + } + }; + + private IExpression CreateCSharpExpression(ExpressionConstructorContext context) + { + var script = context.Element.TryGetProperty("value", out var p) ? p.ToString() : ""; + return new PythonExpression(script); + } +} \ No newline at end of file diff --git a/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs b/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs new file mode 100644 index 000000000..81996bd1d --- /dev/null +++ b/src/modules/Elsa.Python/Services/IronPythonEvaluator.cs @@ -0,0 +1,49 @@ +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