Embedding SteelPython in C#

A .NET application can carry Python inside it: run scripts that users write, call into Python libraries, or let Python code drive the application's own objects. With SteelPython the interpreter is a .NET library in your process - nothing to install on the user's machine, no separate Python, and no marshalling between two runtimes: Python objects and C# objects are passed to each other directly.

Embedding uses the SteelPython NuGet package, part of the SteelPython SDK, which is available to corporate users (see Getting SteelPython).

A first application

Reference the package from a .NET 10 project:

<ItemGroup>
  <PackageReference Include="SteelPython" Version="0.1.0" />
</ItemGroup>
using SteelPython.Hosting;
using SteelPython.Interop;

using var engine = PythonEngine.Initialize();   // finds the standard library next to the application
engine.Expose<Order>(module: "shop");            // Python can now: from shop import Order

engine.Execute("""
    from shop import Order

    def summarize(orders):
        total = sum(o.Quantity * o.Price for o in orders)
        return f"{len(orders)} orders, {total:.2f} in total"
    """);

var orders = new List<Order> { new("bolts", 900, 0.12m), new("nuts", 1200, 0.08m) };
Console.WriteLine(engine.Main["summarize"].Call(orders).As<string>());   // 2 orders, 204.00 in total

[PythonExport]
public record Order(string Item, int Quantity, decimal Price);

dotnet run runs it. The package copies Python's standard library next to your application when it builds, so the output folder is complete and can be copied anywhere.

The engine

PythonEngine.Initialize() starts the interpreter; disposing the engine runs Python's atexit handlers and shuts it down. There is one engine per process; use scopes (below) to keep different scripts apart.

var options = new PythonEngineOptions
{
    Stdout = myWriter,          // sys.stdout (also Stderr and Stdin): any TextWriter / TextReader
};
options.SearchPaths.Add(Path.Combine(AppContext.BaseDirectory, "scripts"));   // added to the front of sys.path
options.Argv.Add("myapp");                                                     // sys.argv
using var engine = PythonEngine.Initialize(options);

Running code and getting values

engine.Execute("import math\nr = math.sqrt(2)");      // statements, in the __main__ module
double r = engine.Main["r"].As<double>();
int n = engine.Evaluate<int>("len('SteelPython')");     // an expression: 11

PythonObject math = engine.Import("math");              // import a module
double root = math.Invoke<double>("sqrt", 2.0);         // call one of its functions

engine.Execute("import greet");                         // greet.py, from a folder in SearchPaths
string hello = engine.Evaluate<string>("greet.hello('C#')");

PythonObject is a handle to any Python object. It has Call(...) (arguments convert from C# automatically; a dictionary of keyword arguments is a second overload), Invoke(name, ...) for methods, GetAttr/SetAttr, an indexer for obj[key], Length, Contains, iteration with foreach, and conversions: As<T>(), AsList<T>(), AsDictionary<K, V>(). ToString() is Python's str(), Repr() its repr().

Scopes

A scope is a separate global namespace, so scripts do not see each other's variables:

var scope = engine.CreateScope();
scope.Set("price", 19.99m);
engine.Execute("with_tax = price * 2", scope);
decimal withTax = scope.Get<decimal>("with_tax");     // 39.98
scope.ExecuteFile("rules.py");

Modules imported in one scope are shared with the others, as within one Python program; for complete isolation, use separate processes.

Errors

A Python exception arrives in C# as a PythonException:

try { engine.Execute("1 / 0"); }
catch (PythonException e)
{
    Console.WriteLine($"{e.PythonType}: {e.PythonMessage}");   // ZeroDivisionError: division by zero
    Console.WriteLine(e.PythonTraceback);                       // formatted as Python prints it
}

Giving Python access to your application

engine.Expose<T>() makes a C# type importable, by default from a module named after its namespace; mark the type [PythonExport] for a fast binding generated at compile time (the attributes). For small things, a module built in code:

engine.RegisterModule("host", m =>
{
    m.Function("tax_rate", (string region) => region == "EU" ? 0.2 : 0.1);
    m.Action("log", (string message) => logger.Info(message));
    m.Value("VERSION", "1.0");
});
import host
host.log(f"rate is {host.tax_rate('EU')}")

Python objects as C# interfaces

Mark an interface [PythonInterface], and any Python object with matching methods and attributes can be used as that interface in C#:

[PythonInterface]
public interface IPricingRule
{
    string Name { get; }
    decimal Apply(decimal total);
}

IPricingRule rule = engine.Evaluate("make_rule()").As<IPricingRule>();
decimal discounted = rule.Apply(10m);       // calls the Python method

This is the natural shape for plug-ins: the application defines the interface, users write Python classes that implement it. Python classes can also derive from C# classes marked [PythonSubclassable] and override their virtual methods (example).

Async

Python coroutines become .NET tasks, and .NET tasks can be awaited in Python:

engine.Execute("""
    async def fetch(n):
        import asyncio
        await asyncio.sleep(0.01)
        return n * 2
    """);
int doubled = await engine.Main["fetch"].Call(21).AsTask<int>();   // 42

AsTask runs the coroutine on an asyncio event loop that the engine keeps on a background thread, and accepts a CancellationToken, which cancels the Python task. AsAsyncEnumerable<T>() turns an async generator into an IAsyncEnumerable<T>.

Threads

There is no global interpreter lock: the engine can be used from several threads at once, and Python code called from different threads runs in parallel. Python's usual rules for shared state apply - protect compound updates with a lock.

Packages and extension modules

Install packages into a folder with SteelPython's pip, and add the folder to SearchPaths:

spy -m pip install --target python-packages requests pandas
options.SearchPaths.Add(Path.Combine(AppContext.BaseDirectory, "python-packages"));

Packages with C extensions (numpy, pandas and the like) work in an embedded engine too: the NuGet package also copies the native part SteelPython needs for them next to your application.

Publishing

dotnet publish produces a folder with your application, the SteelPython runtime and the standard library. SteelPython also works in applications compiled with Native AOT (dotnet publish -r win-x64 -p:PublishAot=true), which start faster and need no .NET runtime on the target machine; there, Python reaches the C# types the application exposed (with [PythonExport] and the other attributes), while clr's reflection over arbitrary .NET types is not available.

Project property Effect
<SteelPythonCopyStdlib>false</SteelPythonCopyStdlib> Do not copy the standard library; set PythonEngineOptions.HomePath to a folder holding Lib\ and lib_steel\ instead
<SteelPythonCopyCApiShim>false</SteelPythonCopyCApiShim> Do not copy the native part needed for C extension modules
<SteelPythonTuneRuntime>false</SteelPythonTuneRuntime> Keep your project's own .NET garbage collector settings (by default the package applies the settings Python code runs best with)