简体中文
English 简体中文 繁體中文
Methodox 首页

将 SteelPython 嵌入到 C# 中

.NET 应用程序可以在内部携带 Python:运行用户编写的脚本、调用 Python 库,或让 Python 代码驱动应用程序自身的对象。使用 SteelPython 时,解释器是你进程中的一个 .NET 库——无需在用户的机器上安装任何东西,不需要单独的 Python,也不需要在两个运行时之间进行封送:Python 对象和 C# 对象直接相互传递。

嵌入功能使用 SteelPython NuGet 包,它是 SteelPython SDK 的一部分,面向企业用户提供(请参阅 获取 SteelPython)。

第一个应用程序

在 .NET 10 项目中引用该包:

<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 运行它。该包会在构建时把 Python 标准库复制到应用程序旁边,因此输出文件夹是完整的,可以复制到任何地方。

引擎

PythonEngine.Initialize() 启动解释器;释放引擎时会运行 Python 的 atexit 处理程序并将其关闭。每个进程只有一个引擎;请使用作用域(见下文)将不同的脚本彼此隔开。

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);

运行代码并获取值

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 是指向任意 Python 对象的句柄。它提供 Call(...)(实参会自动从 C# 转换;接受关键字实参字典的是第二个重载)、用于调用方法的 Invoke(name, ...)、GetAttr/SetAttr、用于 obj[key] 的索引器、Length、Contains、用 foreach 迭代,以及类型转换:As<T>()、AsList<T>()、AsDictionary<K, V>()。ToString() 相当于 Python 的 str(),Repr() 相当于其 repr()。

作用域

作用域是一个独立的全局命名空间,因此各脚本看不到彼此的变量:

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");

在一个作用域中导入的模块会与其他作用域共享,就像在同一个 Python 程序中一样;如需完全隔离,请使用不同的进程。

错误

Python 异常到达 C# 时是一个 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
}

让 Python 访问你的应用程序

engine.Expose<T>() 会让一个 C# 类型变得可导入,默认从以其命名空间命名的模块中导入;给该类型标记 [PythonExport],即可获得在编译时生成的快速绑定(相关特性)。对于小型功能,可以在代码中构建一个模块:

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 对象用作 C# 接口

给接口标记 [PythonInterface] 后,任何具有匹配方法和属性的 Python 对象都可以在 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

这是插件的自然形态:应用程序定义接口,用户编写实现该接口的 Python 类。Python 类也可以派生自标记为 [PythonSubclassable] 的 C# 类,并重写其虚方法(示例)。

异步

Python 协程会变成 .NET 任务,.NET 任务也可以在 Python 中被 await:

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 会在引擎于后台线程上维护的 asyncio 事件循环中运行协程,并接受一个 CancellationToken,用于取消 Python 任务。AsAsyncEnumerable<T>() 会将异步生成器转换为 IAsyncEnumerable<T>。

线程

没有全局解释器锁:可以同时从多个线程使用引擎,从不同线程调用的 Python 代码会并行运行。Python 关于共享状态的常规规则依然适用——请用锁保护复合更新。

包与扩展模块

用 SteelPython 的 pip 将包安装到某个文件夹中,然后把该文件夹添加到 SearchPaths:

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

带有 C 扩展的包(numpy、pandas 等)在嵌入式引擎中同样可用:NuGet 包还会把 SteelPython 为它们所需的原生部分复制到应用程序旁边。

发布

dotnet publish 会生成一个文件夹,其中包含你的应用程序、SteelPython 运行时和标准库。SteelPython 也可以在用 Native AOT 编译的应用程序中使用(dotnet publish -r win-x64 -p:PublishAot=true),这类应用程序启动更快,目标机器上也不需要 .NET 运行时;在这种情况下,Python 可以访问应用程序公开的 C# 类型(通过 [PythonExport] 及其他特性),但无法使用 clr 对任意 .NET 类型的反射。

项目属性 效果
<SteelPythonCopyStdlib>false</SteelPythonCopyStdlib> 不复制标准库;改为将 PythonEngineOptions.HomePath 设置为包含 Lib\ 和 lib_steel\ 的文件夹
<SteelPythonCopyCApiShim>false</SteelPythonCopyCApiShim> 不复制 C 扩展模块所需的原生部分
<SteelPythonTuneRuntime>false</SteelPythonTuneRuntime> 保留你项目自己的 .NET 垃圾回收器设置(默认情况下,该包会应用最适合 Python 代码运行的设置)