当前位置:首页>python>DotNetPy实战:C#里跑Python,到底能不能“引用”一整个目录?

DotNetPy实战:C#里跑Python,到底能不能“引用”一整个目录?

  • 2026-09-02 21:41:36
DotNetPy实战:C#里跑Python,到底能不能“引用”一整个目录?

做 .NET 的人,多半都碰到过这种别扭场面。

业务算法同事甩过来一坨 Python;或者某个开源模型、报表脚本、数据处理流水线,早就在 .py 里跑得好好的。你总不能为了对接,连夜重写成 C# 吧。重写一次,后面每次算法一改,你还得再跟一遍——这活儿不叫开发,更像转译苦力。

于是有人会问:C# 里能不能直接把某个目录下的 Python 文件“引用”进来?用 DotNetPy 的时候,系统是不是自动给 Python 开了个沙盒?

这两个问题,听起来简单,实战里却老踩坑。今天就顺着真实用法往下拆。


先把误会捅破

不少同学一听“.NET 调用 Python”,脑子里会自动浮出两种画面:

一种是:跟引用 DLL 似的,项目里右键添加,目录一挂,方法直接蹦出来。
另一种是:跟浏览器沙箱似的,Python 再怎么作,也伤不到宿主进程。

抱歉,这两种想象,和 DotNetPy 的真实脾气差得有点远。

DotNetPy 做的事情,本质上更朴素——它把 CPython 嵌进你的 C# 进程,走 Python C API 交互,顺带把 GIL、类型转换这些脏活兜住。你写的不是“程序集引用”,而是“在同一进程里,让 Python 解释器执行代码、导入模块”。

一句话:

它能用目录里的 .py,但靠的是 Python 自己的导入机制;它能管环境,但管的不是安全沙盒。


痛点到底在哪

C# 调 Python,真正磨人的往往不是“能不能跑起来”,而是这三件事:

  1. 1. 模块怎么组织
    单个脚本还好办。一旦目录里出现 utils.py、models/、config/ 互相 import,路径稍有偏差,全线 ModuleNotFoundError。
  2. 2. 环境怎么稳住
    开发机有 Python 3.11,测试机是 3.10,生产机压根没装。依赖再一飘,现场直接变玄学。
  3. 3. 边界怎么画清
    很多人默认“嵌入 = 隔离”。结果脚本里随手写了删文件、访问网络、死循环,宿主进程一起遭殃。

我在项目里见过最典型的翻车:
Demo 阶段用字符串执行两行 Python,上线时把整个算法目录塞进来,结果本地跑得通,服务器 import 全挂——因为工作目录变了,相对路径全废。

所以别急着堆 API,先把加载模型和隔离边界想明白。


DotNetPy 到底是什么角色

可以把它想成一座桥,而不是一个保险箱。

C# 进程
└── DotNetPy
    └── 嵌入的 CPython
        ├── 执行代码字符串 / 文件内容
        ├── 按 sys.path 规则 import 模块
        └── 使用当前解释器对应的依赖包

几个关键点,建议直接记:

  • • 同一进程:Python 和 C# 共享进程空间
  • • GIL 仍在:多线程场景得老实面对
  • • 环境可管:尤其配合 uv,能把解释器版本和依赖钉住
  • • 不是沙箱:权限大致等同于你的宿主进程权限

如果你要的是“不可信脚本扔进来也没事”,DotNetPy 不是那把钥匙。
如果你要的是“可信业务脚本、算法模块,低摩擦嵌进 .NET 服务”,它就顺手多了。


目录里的 py,究竟怎么“引用”

这里说的“引用”,请立刻从 C# 项目系统里跳出来。
更准确的说法是:让 Python 找到并加载这些文件。

下面给三条由浅入深、能直接落地的路。

方案一:单文件直读执行

适合脚本独立、没有复杂包结构的场景。比如一段特征清洗、一个临时计算函数。

using DotNetPy;

namespaceAppDotNetPyDemo
{
internalclassProgram
    {
staticvoidMain(string[] args)
        {
            Python.Initialize(); 

var executor = Python.GetInstance();

var pyFile = Path.GetFullPath(@"python_scripts\pricing.py");
var code = File.ReadAllText(pyFile);

// 执行脚本,将 calc_price 函数定义注入全局命名空间
            executor.Execute(code);

// 调用函数并获取结果
var result = executor.Evaluate("calc_price(12, 9.5)");

// 根据 pricing.py 的实际返回类型选择对应方法
            Console.WriteLine($"计算结果: {result?.GetDouble()}");
        }
    }
}

pricing.py 可以简单点:

defcalc_price(qty, base):
# 量越大,折扣越明显
    discount = 0.95if qty >= 10else1.0
returnround(qty * base * discount, 2)

优点:直观,调试快。
缺点:文件之间一旦互相依赖,这条路很快变堵。
踩坑点:Execute 只是执行文本,不会自动帮你把该文件所在目录塞进 sys.path。旁边模块一 import,容易挂。


方案二:把目录挂进 sys.path,再按模块 import

这是目录场景里最常用、也最稳的做法。差不多可以把它理解成“运行期手动加引用路径”。

using DotNetPy;

namespaceAppDotNetPyDemo
{
internalclassProgram
    {
staticvoidMain(string[] args)
        {
            Python.Initialize();

var scriptDir = Path.GetFullPath("python_scripts");

// 路径处理务必用原始字符串思维,避免反斜杠被吞
            Python.Execute($@"
import sys
from pathlib import Path

root = r'{scriptDir}'
if root not in sys.path:
    sys.path.insert(0, root)
"
);

// 之后就像普通 Python 项目一样用
            Python.Execute("import billing");
var result = Python.Evaluate("billing.make_invoice(3, 128.0)");
            Console.WriteLine(result);
            Console.WriteLine($"计算结果: {result?.GetDouble()}");
        }
    }
}

对应目录:

python_scripts/
  billing.py
  utils.py

billing.py:

from utils import with_tax

defmake_invoice(qty, unit_price):
    raw = qty * unit_price
return with_tax(raw)

utils.py:

defwith_tax(amount, rate=0.06):
returnround(amount * (1 + rate), 2)

为什么这个方案香?

因为 Python 开发者本来就生活在 import 体系里。你把目录口径对齐后,算法侧几乎不用为 .NET 改结构,协作成本会小一截。

真实业务里我会固定三件事:

  1. 1. 始终传绝对路径
  2. 2. sys.path 只 insert 一次,避免越插越乱
  3. 3. 模块名避开 test、string 这类易冲突名字

方案三:runpy.run_path,按文件路径拉命名空间

有时你只想跑某个文件,又希望拿到它的函数对象,不想污染太多全局命名。可以试试:

using DotNetPy;

namespaceAppDotNetPyDemo
{
internalclassProgram
    {
staticvoidMain(string[] args)
        {
            Python.Initialize();

var scriptDir = Path.GetFullPath(@"python_scripts").Replace(@"\", @"\\");

            Python.Execute($@"
import sys
sys.path.insert(0, r'{scriptDir}')

import runpy
ns = runpy.run_path(r'{scriptDir}\\billing.py')
result = ns['make_invoice'](5, 20)
"
);

var result = Python.Evaluate("result");
            Console.WriteLine($"计算结果: {result?.GetDouble()}");
        }
    }
}

这条路适合“工具脚本入口明确、但不想整理成正规包”的灰区场景。
不过文件内部如果还 import 同目录兄弟模块,你依旧得保证 sys.path 正确。它不是万能钥匙。


那么,DotNetPy 有没有给你建沙盒?

短答案:没有,至少不是安全意义上的沙盒。

长一点说,要把“环境隔离”和“安全隔离”掰开。

它更擅长的是环境管理

尤其你用 uv 管 Python 版本和依赖时,体验会接近“项目级虚拟环境”:

  • • 解释器版本可钉死
  • • 第三方包不跟全局环境搅和
  • • 部署时少很多“我机器上明明可以”

这解决的是:
可重复、可部署、少车祸。

它不提供的是安全沙箱

因为默认模型是进程内嵌入:

你以为的
实际情况
脚本崩了只影响 Python
宿主进程也可能一起抖
文件访问被自动限制
权限基本随宿主进程
死循环无关痛痒
线程/资源一样可能被拖死
每次调用都是全新囚笼
解释器状态通常可复用、可污染

所以可以这么记:

  • • 环境隔离 ≈ 衣服分柜放,不串味
  • • 安全沙盒 ≈ 把人关进独立房间,还上锁

DotNetPy 帮你做的是前者,不是后者。

若脚本来源不可信,优先考虑:

  • • 独立进程 + 最小权限账号
  • • 容器限制 CPU/内存/文件系统
  • • 更严格的 RPC 边界

别指望“嵌进去就自动安全”。


一套更像生产的写法

下面这段更接近我实际会放进服务里的骨架:初始化一次、路径挂靠一次、业务多次调用。

using DotNetPy;
using System;
using System.Collections.Generic;
using System.Text;

namespaceAppDotNetPyDemo
{
publicsealedclassPyBillingGateway : IDisposable
    {
privatebool _ready;

publicvoidInitialize(string scriptRoot)
        {
if (_ready) return;

            Python.Initialize();

var root = Path.GetFullPath(scriptRoot);
if (!Directory.Exists(root))
thrownew DirectoryNotFoundException($"Python目录不存在: {root}");

            Python.Execute($@"
import sys
root = r'{root}'
if root not in sys.path:
    sys.path.insert(0, root)
import billing
"
);

            _ready = true;
        }

publicdoubleMakeInvoice(int qty, decimal unitPrice)
        {
if (!_ready) thrownew InvalidOperationException("先初始化");

// 简单场景可用 Evaluate;复杂对象建议做一层明确封装
var result = Python.Evaluate($"billing.make_invoice({qty}, {unitPrice})");
return result?.GetDouble() ?? 0;
        }

publicvoidDispose()
        {
// 按你使用的 DotNetPy 版本补充释放逻辑
// 重点是:生命周期要和宿主服务对齐,别来回反复初始化
        }
    }
}

调用侧:

usingvar py = new PyBillingGateway();
py.Initialize("./python_scripts");

var fee = py.MakeInvoice(qty: 4, unitPrice: 99.0m);
Console.WriteLine($"发票金额: {fee}");

这个封装看起来土,但管用。
它把“Python 细节”关在门后,业务代码只看到清晰的 C# 方法。后面你要换加载策略、加日志、加超时,也有地方下手。


性能和稳定性,这几处别省

1. 初始化别放在热路径

Python.Initialize()、挂 sys.path、import 大模块,都应该在启动阶段做完。
请求进来再初始化,延迟会被用户一眼看见。

2. 少在循环里来回 Execute 大段代码

能 import 一次再调函数,就别每次把整文件读进来执行。
前者像把工具箱放桌上;后者像每用一次螺丝刀都重开一遍仓库门。

3. 注意 GIL 和并发模型

多线程 C# 服务里,Python 段不一定按你想的那样并行。
CPU 密集算法,要么接受限制,要么把重活丢到独立进程池。

4. 路径、编码、工作目录分开看

  • • 模块搜索看 sys.path
  • • 文件读写看当前工作目录
  • • 中文路径/内容注意编码

这三者不是一回事。很多人混着排障,会越查越晕。

5. 给 Python 侧约定“纯函数边界”

能让 Python 吃基础类型、吐基础类型,就别一上来跨一堆复杂对象图。
边界越瘦,两端越不容易在类型转换上打架。


常见误区清单

误区 1:把脚本目录当项目引用
C# 的引用是编译期概念;Python 模块是运行期查找。两者别硬套。

误区 2:以为读了文件就自动解决依赖
ReadAllText + Execute 不等于 pip install,也不等于包导入完成。

误区 3:把虚拟环境当成沙箱
venv/uv 防的是依赖污染,不是恶意行为。

误区 4:开发机绝对路径写死进生产
今天能跑,明天换磁盘布局就全灭。路径收敛到配置,启动时解析。

误区 5:错误信息只看 C# 异常表层
很多问题要连着 Python traceback 一起看。桥两端的日志都要留。


什么时候该用,什么时候别硬上

比较适合:

  • • 内部可信的算法/脚本复用
  • • 已有 Python 生态组件(报表、数据清洗、特定 SDK)
  • • 希望 .NET 负责工程化,Python 负责计算表达

谨慎或换方案:

  • • 脚本来源不信任
  • • 需要强隔离、强限流、强审计
  • • 超高并发、超低延迟的核心路径

技术选型不是追新,是匹配约束。
DotNetPy 解决的是“嵌得顺”,不是“防得死”。


可直接拿走的实践模板

模板 A:目录型算法包

AppHost/
  Program.cs
  python_scripts/
    billing.py
    utils.py
    requirements.txt

原则:

  1. 1. 启动时 Initialize + 注入 script root
  2. 2. 业务只调用 C# 网关方法
  3. 3. Python 包内相对 import 保持正常

模板 B:调用约定

  • • C# -> Python:基础类型、JSON 字符串优先
  • • Python -> C#:返回值可预期、可转换
  • • 异常:Python 业务异常映射成明确的 .NET 异常类型

够用了。很多系统毁在“两端都太聪明”,不毁在“能力不够”。


三个值得钉在墙上的判断

  1. 1. 目录能用,靠的是 sys.path + import,不是项目引用魔法。
  2. 2. 环境可隔离,安全默认不隔离;别把嵌入当沙箱。
  3. 3. 生产稳定性,往往来自生命周期管理,而不是又找了个新库。

收束一下

回到开头那两个问题:

DotNetPy 能不能直接引用一个目录下的 py 文件?
能使用,方式是把目录纳入 Python 模块搜索路径,再正常 import;或读取文件执行。它不是 C# 那种“添加引用”体验。

在 C# 中调用时,是不是给对应 Python 创建了沙盒环境?
如果你指依赖与解释器环境,可以管得比较干净;
如果你指安全沙箱,默认不是。它是进程内协作,不是囚笼。

我自己的建议很现实:

  • • 目录模块化,用 sys.path 方案做主路径
  • • 初始化一次,重复调用函数
  • • 用 uv 一类工具把版本和依赖钉牢
  • • 可信代码内嵌,不可信代码外置隔离

C# 擅长把系统搭结实,Python 擅长把表达式写痛快。两者接在一起时,别追求“全自动神奇”,先把路径、生命周期、边界这三块砖铺平。砖铺平了,后面反而少加班。


你可以怎么继续练手

  1. 1. 先用方案二跑通一个两文件互 import 的小目录
  2. 2. 再包一层 C# Gateway,把 Python 细节藏起来
  3. 3. 最后补上启动检查:目录是否存在、模块能否 import、依赖是否齐全

相关关键词也能顺着挖:pythonnet 对比、GIL 与线程池、uv 在 CI 里的锁定策略、进程外 Python Worker。


核心收获就三句:
按模块导入,不要幻想项目引用;按环境管理,不要误认安全沙盒;按生命周期设计,不要每次请求现场搭台。

学习路线上,你可以先熟悉 CPython 嵌入和模块导入规则,再补 .NET 侧宿主生命周期与并发模型,最后才是部署链路里的依赖锁定。循这个顺序,少走弯路。

觉得有用的话,欢迎微信打赏鼓励一下,让我有动力继续输出这类实战内容。完整源码结构已在各节逐一呈现,可结合项目实际直接落地;如需完整源码,可在公众号聊天窗口获取。


标签:C#开发DotNetPyPython互操作编程技巧.NET实战

最新文章

随机文章