PyriteIDE 开发杂谈:语言服务器、插件系统与 Python 运行时

PyriteIDE 开发杂谈:语言服务器、插件系统与 Python 运行时

W-Can1425 Lv3

距离上一篇文章已经整整过去了 401 天,秋冬春夏秋。

过去一年的时间里,我一直将精力花费在 Pyrite Project 或者更细致地说是 PyriteIDE 的开发工作上。PyriteIDE 是一个跨平台且现代化的 MicroPython 编辑器,是 Pyrite Project 的重要部分,致力于为社区提供下一代 MicroPython 应用层开发工具。

语言服务器、插件系统与 Python 运行时是 PyriteIDE 的重要组成部分,也是我花了很长时间探索和试错的部分,根据 git history,我与这三位幻神的故事应该从 2026.2.4 开始。

请注意,该文章不应作为技术文档,技术解析等正式内容,偏向于记录我个人的开发实践和成果,存在大量不严谨的表达,不必特别在意。

背景

语言服务器,通过语言服务器协议与 IDE 进行通信,向 IDE 提供语言功能,提高开发者的开发效率,对 LSP 的支持是现代 IDE 不可或缺的能力。插件系统很好理解,增强软件的可拓展性。

语言服务器、插件系统、 Python 运行时,这三个东西看起来没有实质性的关联,但是在现在已经成熟的 PyriteIDE 技术架构中,他们却拥有一个上下级关系。运行时是底层基础,语言服务器和插件系统都运行在它之上。在目前的设想中,语言服务器还可以作为一个普通插件进入 PyriteIDE。

语言服务器在一般情况下是一个独立进程,但 PyriteIDE 的跨平台属性意味着我们注定要碰上一些麻烦。在早期的设计中,PyriteIDE 是移动端优先的(曾经我的构想是为了给 mPython for Android 续命),iOS 自然是不用提,根本做不了,所以这个移动端优先其实是 Android 平台优先。

Android 系统严格控制应用程序的能力,在 Android 平台上启动一个进程并且还要与它通信这本身就是一件难事,况且我没听说过某个语言的开发环境能被独立部署在 Android 系统上的。所以,想要移动端用户像桌面端用户一样安装好某个 Python LSP 然后提供给 IDE 来使用不是很好做。

在早期设想中,插件系统并不准备在 v1 阶段就正式上线,更多的是设想与展望。我们多次讨论过插件系统的可行性,我的观点一直是往后拖一拖:

Can:难如登天

关于 Python 运行时

方案一:在 Android 上部署轻量级的 Linux

要通过一些奇技淫巧在 Android 环境上跑起来不是一件简单事。社区中已经有了很多成熟且强大的 Android 平台上的 IDE,可以使用它们在 Android 设备上开发复杂的项目。它们的工具链是如何在 Android 上运行的呢?Android Code Studio 是直接在他们的项目中嵌入了 Termux,Choccy 是在本地自己部署 proot 容器。

这种做法优劣鲜明,优点当然是能在非 Root 的情况下提供最完整的 Linux 环境,缺点就是应用存储占用将会严重膨胀。我在本地安装了 Choccy,在完整部署的情况下将近使用了 12G 的存储空间。(现在我倒不是很在意这个,我的手机存储空间足足有 1T 给我折腾,但是用户恐怕就不会买账了)

况且此时只解决了环境的问题,语言服务器的部署没有问题,但是 IDE 如何在 proot 容器外面启停、连接、通信又成了问题。技术力限制想象力,我此时想不到任何解决方案。

早期我考虑过模仿他们在应用内嵌入并部署一些轻量级的 Linux 系统,但是最终这个方案被放弃。

方案二:在 Android 上自部署 Python 运行时

1
2
2026.2.8 ea851ddd0abdae9a761e5741efb79e08b4e197d9
feat!: added automatic environment deployment for the Android platform

现在的语言服务器都在追求高性能,当时找了一圈几乎没有一个 Python 语言服务器是用 Python 写的,这也是驱动我早期倾向于部署容器的原因。但是 python-lsp-server(pylsp) 竟然使用纯 Python 技术栈开发,这让我看见了新的希望,即不部署完整操作系统,只在 Android 上部署 Python 运行时。

Android 环境下是可以直接运行二进制可执行文件的,这个大家都知道,但是能在 Android 环境下直接跑的二进制文件从哪来,这个恐怕不是很好说。我使用了一种非常幽默的办法,即在 Termux 中安装好 Python,然后去 Termux 的应用私有目录去提取:

提取之后,我将其打包为 .zip 作为应用资源文件随应用一并打包,并写了代码在应用启动时检查在 IDE 的私有目录下是否存在部署后的文件,如果没有,从应用资源文件中取出 .zip 并解压到应用私有目录。

就是如此朴实无华的逻辑,最后竟然真的跑起来了:

遇到的问题固然是很多的,具体我记不清了,这里有一个非常典型的例子:SELinux 权限问题,在 Android 系统中,当应用声明的 SDK 高于 API28 时,二进制可执行文件放置在应用私有目录也会跟放置在外部存储一样出现 Permission denied。解法就是把 SDK 降回去,但是这种方法副作用有点显著,这里不展开。或者说把可执行文件打包成 .so 放到 .jnilibs,我没有尝试过,不再细谈。

方案三:嵌入式 Python 运行时

1
2
2026.2.21 f6f7ab58323c94b89e6305eb3b3077c5a31e3b08
build: introduce serious_python

方案二整体上能用但是危机四伏,我无法保证在除了我的设备以外的其他设备能使用我提取的产物,并且得益于框架层的一些限制,解压所花费的时间很令人抓狂,恐怕至少需要 15 min 左右。得益于 SELinux 策略,我还不能提升我的应用的 SDK 版本,更重要的是部署后这套运行时也很不稳定。

两年之前我就在关注一个开源项目 Flet,早期它是 Flutter 的 Python 绑定,后来独立成了由 Flutter 驱动的 Python UI 框架。我一直很好奇它是如何做到使用 Python 书写代码,最终将 UI 交给 Flutter 渲染,逻辑代码跨平台运行的。

大概在今年二月左右,有一天我到某个公园去玩,在帐篷里非常无聊的翻 Github 上的 flet-dev 账户,真让我翻到一个名为 serious-python 的项目,正是我梦寐以求的嵌入在每个 Flet 应用中的 Python 运行时:

A cross-platform plugin for adding embedded Python runtime to your Flutter apps.

我花了一点时间(大概两天)研究了一下如何把它引入进我的项目并且让它跑起来:

serious_python 存在诸多问题,我在后来将近三个月的开发中持续深入地进行了一些挖掘,在此基础上进行了一些魔改,分叉为独立的 pyrite-ide-python-runtime。技术细节稍后细说。

关于语言服务器

推动我去探索和开发运行时方案和插件系统最大的外部因素就是这个语言服务器,准确的说是 Python 语言服务器。

对于常见的编辑器项目,关于语言服务器的工作应该是仅限于通信部分,这也是一个难点,至少对于像我一样基础不牢地动山摇的小白来说,手写客户端的通信代码是一个难点。

在 PyriteIDE 这里还有一个麻烦,就是如何以最便携的方式把语言服务器提供的能力带到 Android 平台,经过多方面的考察,我最终锁定了 通过某种方式把 Python 运行时带到 Android 上,然后在这个运行时中跑 pylsp 的方案,具体实现自然就是上面已经提到过的部分。

这里简单谈谈客户端的处理与通信这一部分

提到语言服务器不得不跟编辑器组件联系在一块。早期我使用的是 re_editor 包,这个库很“理直气壮”地不做任何关于语言服务器的适配,包括相关组件全都只给了接口,默认实现都没有,这令我很头疼,所以我直接使用了 AI 生成了包括通信和处理信息在内的几乎所有的代码,我自己进行了大量的测试并且进行修改,这照样令人头疼。

后期我很幸运的找到了 code_forge 包,其内部较为妥善地处理了对语言服务器的支持,并且提供了更强大的编辑器功能,因此我最终将整个编辑器核心迁移至了 code_forge。

我找了一段来自 vscode-language-server 的代码:

1
2
3
4
5
6
export declare enum TransportKind {
stdio = 0,
ipc = 1,
pipe = 2,
socket = 3, // websocket
}

可以看到,对于任意一个服务,只要满足支持使用上述方法方式通信,接口逻辑遵循 LSP 规范进行实现就是语言服务器。

对于一般的实现,显然更多的会是标准输入输出,但是对于我的情况,我根本无法通过可执行文件独立启动语言服务器进程并通过 stdio 连接,正巧 pylsp 允许我通过 WebSocket 方式连接,所以早期所有的设计都默认你通过 WS 方式连接语言服务器。

在运行时被语言服务器问题推向成熟时,多种原因的驱动下我将重心移向了插件系统的开发。内嵌语言服务器的计划被搁置,我更倾向于使用插件的方式提供 pylsp 并启动。

pylsp 存在诸多问题,特别是当它在内嵌的运行时中运行时。在 macOS 上,由于 ujson 包的签名问题导致整个应用无法正常使用,这个问题与我当时使用一些奇特方法打包 ujson 的 whl 有关,或许未来我会补充这部分经历。

迁移至 code_forge 后,由于 code_forge 假设每个语言服务器实现都拥有 textDocument/documentColor textDocument/semanticTokens/range 等请求并大量向 pylsp 发送这些请求,导致收到未知请求的 pylsp 总是卡死,这让我很恼火。

目前我短时间没有再考虑提供即开即用的语言服务器插件了。

关于插件系统

这可能是最有趣的一部分了。背景中提到我早期并不打算引入插件系统,主要原因如下:

  1. 我不会写

是的,主要就是这一个原因。铺开来能说的就多了,当时整个 IDE 仍然处于早期阶段,整个项目基本上就是个空壳,代码编辑,设备连接等基础功能一个都没有。况且写来写去就是我一个人在写,当时我也还没有学会使用 AI/Agent 神力,再加上我较为垃圾的技术水平,我实在是不知道从哪里开始下手。

2026.3.21 晚,我刷 Github 时看到了我两年前关注的一位朋友的动态,即 kaixin1106 ,他的新仓库令我震撼了一下,是一个名为 CrabIDE 的 Python IDE。我立即去B站找他私聊说明来意并询问是否加入我共同开发 PyriteIDE,然后我们加上了微信。

kaixin1106 为我提供了来自 macOS 的开发和测试支持,更重要的是他主攻 Python 并且在开发 PyriteIDE 的途中很快地学会了 Dart/Flutter。我在开发中逐步向他介绍了我已经完成的工作和我的构想,包括关于插件系统的构想。我想的是先说在这里逐步实现,结果没想到他马上就实现了。

3.25(星期三)晚上我谈了一下我的构想,3.27(星期五)晚上放学之后他已经给了我一个成品,我的内心无疑是震撼的。

根据我早期的设想,当时考虑到已经引入了 serious_python,所以执行 Python 代码本身不是一个问题,关键问题转化成了:外部开发者如何通过运行在与客户端隔离的 Python 运行时中 Python 代码,创建动态的 Flutter UI。

首先,大家都知道 Dart 是 AOT 语言,Flutter 框架是跨平台框架,源代码最终针对不同的目标平台进行编译,编译后的产物显然是不可更改的,Dart/Flutter 的动态化本身就是一大问题。商业化的解决方案一般是在应用内内嵌 WebView,这也是客户端“动态化”最初的起源,但是造成的性能损失和与原生组件(此处原生指 Flutter 相对于 Web)之间的割裂是我无法接受的,并且又要处理跨平台 WebView 的问题,搞不好还要处理 JS 和 Dart 通信的问题,得不偿失。

其次,是 Python 代码如何创建 Flutter 组件的问题,与上面的动态化问题合二为一,社区已经有了一些解决方案,早期我们选用了 rfw 包,它用一套自己的语法描述 UI,最终解析为 Flutter 组件并渲染。我们要做的,就是在 PyriteSDK 中提供一套 API,它们并不会创建 UI,它们会在 Python 运行时中将用户使用 Python 代码描述的 UI 合成为 rfw 所需要的具有一定语法的字符串,并通过 WebSocket 与客户端进行通信。

这就是整个插件系统的雏形,我着重说明了 UI 是如何被创建的,至于其他非 UI 类的 API 比较好说,Python API 向客户端发送请求,客户端根据请求,匹配到对应操作,完成或失败后向插件发送回复。

在正式发布后,我们对插件系统进行了一次全面重置,通信的逻辑没有大改,主要是彻底抛弃了 rfw,全部换成手写的匹配代码,直接通过来自插件的请求匹配对应的组件并直接创建到对应视图上,具体来说过于复杂,毕竟这一段代码我并没有手写一行,全部由 AI 代写。

或许这段经历又可以写成一个故事了,在认识 kaixin1106 之后我才开始大规模使用 agentic coding 甚至是 vibe coding,毕竟我和大家一样,一周也只有一个周末。今年九月份之后,我只有半个了。

一些技术细节

请注意,部分内容中的示例代码和描述可能已经在最新提交中失效,此处仅仅是对上文的技术补充。

以下的代码和设计中存在不完备之处,仅供参考。

CPython 生命周期的管理

serious-python 的原生实现是即用即走的模式,在 PyRun_SimpleFileEx(...); 前后直接进行 Py_Initialize(); 和 Py_Finalize(); 初始化和销毁 CPython,对于运行插件的需求而言几乎完全不可用。

我们将初始化和销毁 CPython 的逻辑移出了 RunPythonProgramAsync, RunPythonScriptAsync, RunPythonProgram, RunPythonScript 函数。

将初始化和检查封装进 EnsurePythonInitialized 管理,在上面那些函数中调用,确保全局只初始化一次且在确保已经被初始化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void SeriousPythonWindowsPlugin::EnsurePythonInitialized() {
std::lock_guard<std::mutex> lock(python_mutex_);
if (!python_initialized_) {
Log("Initializing Python interpreter...");
Py_Initialize();
if (!Py_IsInitialized()) {
Log("ERROR: Python initialization failed!");
return;
}
main_thread_state = PyEval_SaveThread();
python_initialized_ = true;
Log("Python initialized successfully, GIL released.");
}
}

将销毁逻辑移入了 SeriousPythonWindowsPlugin 的析构函数中,在类的实例被销毁时销毁 CPython:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
SeriousPythonWindowsPlugin::~SeriousPythonWindowsPlugin() {
Log("Destructor called");
std::lock_guard<std::mutex> lock(python_mutex_);
if (python_initialized_) {
if (main_thread_state) {
Log("Restoring main thread state before finalization");
PyEval_RestoreThread(main_thread_state);
main_thread_state = nullptr;
}
Log("Finalizing Python interpreter");
Py_Finalize();
python_initialized_ = false;
Log("Python finalized");
}
}

完善多线程支持和 Python GIL 管理

基于全局单例的修改,我们在异步线程中使用 PyGILState_Ensure 和 PyGILState_Release。

所有执行 Python 代码的线程(同步或异步)都必须先调用 PyGILState_Ensure(...); 获取 GIL,执行完毕后调用 PyGILState_Release(...); 释放。

1
2
3
4
5
6
7
8
9
10
void SeriousPythonWindowsPlugin::RunPythonProgram(std::string appPath, const EncodableMap& env_vars) {
Log("RunPythonProgram entered for: " + appPath);
PyGILState_STATE gstate = PyGILState_Ensure();
Log("GIL acquired");

...

PyGILState_Release(gstate);
Log("GIL released, RunPythonProgram finished");
}

移除了原来在运行 Python 代码相关逻辑函数内部的 Py_Initialize 和 Py_Finalize 调用。已在上文提及。

另外,我们更改了环境变量的设置时机,将 PYTHONHOME、PYTHONPATH 等环境变量的设置移到 Py_Initialize(); 之前(通过 _putenv_s),确保 Python 启动时能正确读取。

环境变量的传递

在 serious-python 的原生实现中,我们通过平台代码向操作系统传递环境变量,但是由于全局单例,但插件不是单例的设计,导致实际上在 Py_Initialize(); 对于环境变量的修改是完全无效的,os.environ 不会同步操作系统的环境变量。

因此,我们在保留原有在初始化前设置环境变量(用于设置解释器需要的重要环境变量)的前提下,引入了动态修改 os.environ 的逻辑:

Windows 平台:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void SeriousPythonWindowsPlugin::RunPythonProgram(std::string appPath, const EncodableMap& env_vars) {

...

// Update os.environ with provided environment variables
if (!env_vars.empty()) {
std::string updateScript = "import os\n";
for (const auto& kv : env_vars) {
const auto& key = kv.first;
const auto& value = kv.second;
if (auto str_key = std::get_if<std::string>(&key);
auto str_value = std::get_if<std::string>(&value)) {
std::string escaped_value = *str_value;
size_t pos = 0;
while ((pos = escaped_value.find("'", pos)) != std::string::npos) {
escaped_value.replace(pos, 1, "\\'");
pos += 2;
}
updateScript += "os.environ['" + *str_key + "'] = '" + escaped_value + "'\n";
}
}
Log("Updating os.environ:\n" + updateScript);
int ret = PyRun_SimpleString(updateScript.c_str());
if (ret != 0) {
Log("Failed to update os.environ");
PyErr_Print();
}
}

int ret = PyRun_SimpleString("print('Hello from inline Python')\nimport sys; sys.stdout.flush()");
if (ret != 0) {
Log("Inline Python test failed, code=" + std::to_string(ret));
PyErr_Print();
} else {
Log("Inline Python test succeeded");
}

...

}

Android 平台:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Update os.environ with provided environment variables
void updateEnvironmentVariables(Map<String, String>? environmentVariables) {
if (environmentVariables != null) {
final gstate = _cpython!.PyGILState_Ensure();
final updateBuffer = StringBuffer();
updateBuffer.writeln("import os");
for (var v in environmentVariables.entries) {
updateBuffer.writeln("os.environ['${v.key}'] = '${v.value}'");
}
final updateScript = updateBuffer.toString();
spDebug("Updating os.environ:\n$updateScript");
int ret =
_cpython!.PyRun_SimpleString(updateScript.toNativeUtf8().cast<Char>());
if (ret != 0) {
spDebug("Failed to update os.environ");
_cpython!.PyErr_Print();
}
_cpython!.PyGILState_Release(gstate);
spDebug("GIL released, RunPythonProgram finished");
}
}

由于解释器全局单例和在修改前后获取和释放 GIL,我们不用担心修改无效的问题。我们直接使用 CPython 实例执行由平台代码动态生成的 Python 代码,直接修改 os.environ 以达到传递环境变量的效果。

各个插件的环境与模块的管理

由于解释器全局单例的实现,各个独立的插件的运行环境会遭到不可避免的污染,其中模块的问题最为突出。

我们在 serious-python 要求提供的资源文件格式上做出修改,修改主要针对 Android 平台,表现为我们将生成的 site-packages 文件夹一并打包进资源文件中(与桌面平台一致)。

我们假设现在存在两个插件,它们各自的资源文件中拥有不同的 site-packages。我们称呼其中一个为 pluginA,一个为 pluginB。pluginA 和 pluginB 的 site-packages 中各自存在着一个同名模块 this_is_a_test,但是它们的实现完全不同,且 pluginB 完全无法使用 pluginA 的 this_is_a_test 模块。

假设我先在客户端使用 SeriousPython.runProgram 启动 pluginA(解压其资源文件,并运行其中的 __main__.py),再启动 pluginB,(接下来的行为出现的前提是已完成上文的修改)Runtime 将会抛出错误,并且你将会看到错误堆栈中出现 pluginA 的 site-packages 的路径。

这是因为 serious-python 的初始化是从第一次调用 SeriousPython 的方法开始的,此时 Runtime 将会设置环境变量并执行 Py_Initialize();。在 serious-python 的原生实现中,此时将会设置 PATH 并在其中包含该资源文件解压后的目录中的 site-packages。

我们在上文有所提到,虽然原生实现中确实会在每一次执行 SeriousPython.runProgram 的时候象征性的重新设置 PATH 环境变量,但是这无法影响 Python 的内部环境。与解决环境变量问题的思路类似,我们也通过直接修改/覆盖 sys.path 和 sys.modules以达到效果。

为了降低实现难度和平台层的复杂度,我们决定在平台层和插件层 之间加入一个夹层:引导层。

上文已经提到,引导层也是一个普通的 serious-python 资源文件且不包含任何额外依赖项。其中有 boot.py 和 setup_sys_path.py 两个 Python 代码文件。

1
2
3
4
5
6
7
8
9
10
# boot.py
import sys

def _save_original_snapshot():
if not hasattr(sys, '_runtime_original_sys_path'):
sys._runtime_original_sys_path = list(sys.path) # type: ignore
if not hasattr(sys, '_runtime_original_modules_keys'):
sys._runtime_original_modules_keys = set(sys.modules.keys()) # type: ignore

_save_original_snapshot()

客户端将会保证 boot.py 在应用启动时被立即运行,它将会将未经污染的 sys.path 和 sys.modules 的键值存入 sys._runtime_original_sys_path 和 sys._runtime_original_modules_keys 作为快照。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# setup_sys_path.py
import os
import sys

def setup_sys_path():
module_paths_str = os.environ.get('RUNTIME_MODULE_PATHS')
replace = os.environ.get('RUNTIME_REPLACE_MODULE_PATHS') == '1'

if not module_paths_str and not replace:
return

# 将字符串拆分为列表
module_paths = module_paths_str.split("::") if module_paths_str else []

if replace:
if hasattr(sys, '_runtime_original_sys_path'):
sys.path[:] = list(sys._runtime_original_sys_path)

# 清除所有不在原始键集中的第三方模块
if hasattr(sys, '_runtime_original_modules_keys'):
original_keys = sys._runtime_original_modules_keys
for mod in list(sys.modules.keys()):
if mod not in original_keys:
del sys.modules[mod]
# 反向迭代,顺序传入
for path in reversed(module_paths):
if not path:
continue
# 转换为绝对路径,去除末尾斜杠
normalized = os.path.abspath(path).rstrip(os.sep)
if normalized not in sys.path:
sys.path.insert(0, normalized)

# print("[serious_python] sys.path adjusted:", file=sys.stderr)
# for p in sys.path:
# print(" ", p, file=sys.stderr)

setup_sys_path()

客户端将会保证 setup_sys_path.py 在每一次启动插件前运行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// lib/core/sdk/plugin_run_manager_provider.dart
await SeriousPython.runAsset(
"assets/python_runtime_boot.zip",
appFileName: "setup_sys_path.py",
environmentVariables: {
"RUNTIME_MODULE_PATHS": runtimeModulePaths,
"RUNTIME_REPLACE_MODULE_PATHS": "1",
},
);
SeriousPython.runProgram(
path.join(target.path, "__main__.py"),
script: Platform.isWindows ? "" : null,
environmentVariables: {"PYRITE_IDE_PLUGIN_PORT": "$port"},
);

setup_sys_path.py 将会依据快照重置环境。

暂时能想起来的就这么多,如果还有遗漏,还有时间的话我可能还会补充,或者直接问我也行。

好巧不巧,只计手动换行的话,写到这里刚刚好 401 行。

  • 标题: PyriteIDE 开发杂谈:语言服务器、插件系统与 Python 运行时
  • 作者: W-Can1425
  • 创建于 : 2026-10-06 22:41:46
  • 更新于 : 2026-10-06 23:19:56
  • 链接: https://can1425.flowecho.org/2026/10/06/20261006/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论