CLI-Anything QGIS Harness 架构解析:用 PyQGIS + qgis_process 构建有状态的 GIS 命令行代理
本文以 QGIS/agent-harness/QGIS.md 这份架构说明为主体,完整还原 CLI-Anything 中 QGIS harness 的设计决策:为什么把"项目/图层/要素/版式编辑"交给 PyQGIS,而把"处理算法与版式导出"交给 qgis_process --json,并逐命令拆解其数据模型、会话模型、输出模型与三层测试策略。读完后你将掌握一套"贴近真实 QGIS 运行时、不在 Python 里重写后端逻辑"的代理式 CLI 工程方法,并能直接复现从建项、建图层、加要素到 PDF/PNG 导出的完整链路。
一、Harness 定位与后端拆分
QGIS.md 开宗明义:这个 harness 面向的是机器上已经存在的真实 QGIS 运行时,并遵循 cli-anything 的通用 harness 模型——把作者态(authoring state)保存在一个长生命周期的 Python 进程里,同时凡是 QGIS 已经通过 CLI 干净暴露的能力,就直接复用现成后端 CLI,而不是在 Python 里重新实现一遍。
这份"后端拆分(Backend split)"是整个架构的核心,文档把它切成两条线:
1)PyQGIS 负责有状态的作者操作。 凡是会修改或检查内存中项目状态(in-memory project state)的操作都走 PyQGIS,包括:
- 项目的 create / open / save
- 项目 CRS 与 title 的更新
- 可写矢量图层的创建
- 要素(feature)的插入
- 版式(layout)的创建与版式元素的绘制
- 项目 / 图层 / 版式的摘要(summary)输出,供 CLI 展示
这些能力对应的关键 API(文档列出的上游 QGIS 源码位置)是 QgsApplication、QgsProject、QgsPrintLayout / 版式元素,以及 QgsLayoutExporter。需要说明的是,文档中的 QGIS/src/core/...、QGIS/src/analysis/processing/... 等路径指向的是 上游 QGIS 项目源码树,用于佐证 API 出处,并不属于当前仓库,本文仅作来源标注、不生成跳转链接。
2)qgis_process --json 负责处理与导出。 凡是 QGIS 已经作为"稳定的处理算法"存在的能力,都走 qgis_process --json,包括:
- 算法发现(
list) - 算法帮助(
help <id>) - 通用算法执行(
run <id>) - 版式 PDF 导出(
native:printlayouttopdf) - 版式图片导出(
native:printlayouttoimage)
文档同样标注了这些能力的上游参考:qgis_process 入口位于上游 QGIS/src/process/main.cpp、qgsprocess.cpp,版式导出算法位于 QGS/src/analysis/processing/qgsalgorithmlayouttopdf.cpp 等,并有 QGIS/tests/src/python/test_qgslayoutexporter.py 作为示例佐证。
为什么这样拆分
文档" Why this split"一节给出了原则:PyQGIS 是"多条命令间需要保持 live project state"的正确层(REPL 场景下状态要在多次调用之间存活),而 qgis_process 是"处理算法"的正确层,因为 QGIS 本身就随附了一个受支持的 CLI 契约,包含 JSON 输出和算法元数据。这样做的好处是——harness 紧贴 QGIS 本身,而不是把后端逻辑在 Python 里重写一遍。
这一点在当前仓库里被完整落实了。setup.py 的项目描述直接写明了这一分工:
description="CLI harness for QGIS using PyQGIS for project authoring and qgis_process for exports and processing."
见 setup.py。
二、命令分组:如何调查与收窄能力面
文档"How the harness was surveyed"强调:这个 harness 不是从 QGIS/src 自动生成的,而是先调查 QGIS 的稳定运行时表面,再包装一个"窄而可测试"的子集。各命令组的来源在文档中被逐一交代:
project、layer、feature、layout包装的是需要 live project state 的 PyQGIS 作者面;export和process包装的是稳定的qgis_process --json表面,而非在 Python 里重写算法;session则是 harness 自有的 REPL / history 便捷能力,并非 QGIS 原生特性。
文档也借此解释了当前的能力边界(值得注意的"不做"部分):
- harness 有意只暴露一个小的版式面,而非整套桌面版式系统;
- harness 可以直接对 shapefile 及其它数据源路径运行处理算法;
- harness 当前没有提供"把一个已存在的任意 shapefile 加入项目"的一等命令。
这些边界与真实 CLI 定义一一对应。入口 qgis_cli.py 用 click 声明了七个命令组:project、layer、feature、layout、export、process、session,外加一个 REPL。REPL 中的命令帮助表(qgis_cli.py)精确列出了每个组的子命令,与文档描述完全吻合:
command_help = {
"project": "new|open|save|info|set-crs",
"layer": "create-vector|list|info|remove",
"feature": "add|list",
"layout": "create|list|info|remove|add-map|add-label",
"export": "presets|pdf|image",
"process": "list|help|run",
"session": "status|history",
...
}
三、数据模型选择(Data model choices)
文档"Data model choices"一节给出了四个关键设计选择,下面结合源码逐一印证其落地方式。
3.1 项目:立即落盘,裸名默认归一为 .qgz
文档说:项目会立即保存到 .qgz 或 .qgs 路径,且 harness 会把裸输出名默认归一为 .qgz。
这一逻辑落在 core/project.py 的 normalize_project_path 中:
def normalize_project_path(path: str) -> str:
target = Path(path).expanduser()
if target.suffix.lower() not in {".qgs", ".qgz"}:
target = target.with_suffix(".qgz")
return str(target.resolve())
create_project 则通过 project.setFileName(...) + project.write() 保证新建即落盘,并在写入失败时抛出 QgisBackendError(core/project.py)。open / save 命令都复用同一套归一化与单例 QgsProject.instance()。
3.2 可写图层:项目侧 GeoPackage 而非临时内存层
文档选择把新矢量图层创建为项目侧的 GeoPackage 图层,而不是 ephemeral 内存层。默认数据存储(datastore)从项目路径派生:
<project>.qgz→<project>_data.gpkg
这样作者态数据始终在磁盘上,后续处理 / 导出命令都能对着真实数据集工作。
源码中 default_datastore_path 精确实现了这一命名规则(core/project.py):
def default_datastore_path(project_path: str | None = None) -> str:
path = Path(project_path or require_saved_project_path())
return str(path.with_name(f"{path.stem}_data.gpkg"))
而 create_vector_layer(core/layers.py)的实际写盘流程非常讲究:先构造一个临时内存图层("memory" provider)用于定义字段,再用 QgsVectorFileWriter.writeAsVectorFormatV3 以 GPKG 驱动把空结构写入旁路 GeoPackage,最后用 ogr provider 以 |layername=... 形式重新打开并 addMapLayer 到项目。若 GeoPackage 已存在且含同名图层,会设置 CreateOrOverwriteLayer。这套"内存定义 → 落盘 GPKG → OGR 重开"的三段式,正是文档所说"让数据落在磁盘上、让后续命令对着真实数据集工作"的具体实现。
字段类型与几何类型也做了受控映射(core/layers.py):FIELD_TYPES 支持 int/double/string/bool(含别名),GEOMETRY_TYPES 支持 point/linestring/polygon。CLI 侧的 layer create-vector 用 --field name:type 可重复传入,未显式给 --crs 时回退到项目 CRS,再回退 EPSG:4326(qgis_cli.py)。
3.3 要素:WKT 几何 + 可重复 --attr,按声明类型强转
文档说:要素插入接受 WKT 几何与可重复的 --attr key=value,且属性会对照 QGIS 声明的字段类型做强制转换(coerce)——CLI 输入保持简单,而存储层的 schema 才是权威。
这在 core/features.py 中体现为 _coerce_value:对 Int/Double/Bool 分别做 int() / float() / 布尔白名单(true/1/yes/on、false/0/no/off)转换,非法布尔直接抛错;add_feature 先用 QgsGeometry.fromWkt(wkt) 解析并校验非空,再逐字段按 layer.fields() 填充,最后 dataProvider().addFeatures(...) 并 updateExtents()(core/features.py)。"未提供的字段填 None"这一细节保证了存储 schema 的完整性。
3.4 版式:v1 只实现窄而实用的表面
文档明确 v1 只实现一个"窄但有用"的版式面:
- 创建 / 删除 / 列出版式
- 添加地图元素(add map items)
- 添加标签元素(add label items)
- 当未显式给 extent 时,从当前项目图层推导地图范围
最后这条"自动范围推导"在 core/layouts.py 的 _combined_project_extent 中实现:遍历项目的矢量与栅格图层,用 combineExtentWith 合并范围,并对"点状零面积"范围做 grow(1.0) 兜底——这正是后面测试里反复出现的"point-only 项目也能 add-map"回归路径。页面尺寸在 PAGE_SIZES = {"A4","A3","A2","A1","A0","LETTER"} 白名单内校验,方向仅允许 portrait / landscape(core/layouts.py)。
四、会话模型(Session model)与输出模型(Output model)
会话模型
文档描述 CLI 维护一个轻量会话状态,跟踪:当前项目路径、modified 标志上报、命令历史;REPL 是未传子命令时的默认模式,而一次性命令仍可通过 --project 把命令绑定到某个已保存项目路径。
实现上,会话文件位于 ~/.cli-anything-qgis/session.json(qgis_cli.py),由 Session 类管理(core/session.py)。值得注意的工程细节:
- 每次
record/set_project_path都会触发_auto_save,且_locked_save_json使用fcntl.flock排它锁 +truncate原子写,避免并发损坏(core/session.py); - 每条历史项
HistoryEntry带 UTC 时间戳,status会汇报modified(由project.isDirty()得出)与history_count; - 命令级
_record只对结果里的若干"摘要键"(path/output/title/layer_count/...)做剪枝,避免把大对象写进历史(qgis_cli.py)。
"REPL 为默认模式"由 @click.group(invoke_without_command=True) 实现:当 ctx.invoked_subcommand is None 且参数不含 --help 时自动 ctx.invoke(repl)(qgis_cli.py)。
输出模型
文档强调:每个命令都通过 --json 提供稳定的 JSON 形状;人类可读输出是次要的;面向 Agent 的用法应优先 JSON。
源码里这由全局 _json_output 开关驱动:output() 在 JSON 模式下直接 json.dumps(..., indent=2, default=str),否则走缩进式人类可读打印(qgis_cli.py)。错误处理同样双通道:handle_error 装饰器把 QgisBackendError / QgisProcessError / ValueError 归一成 _error_payload,JSON 模式下输出 error/type(以及 returncode/stderr/stdout/payload),人类模式下打印 Error: ...,且仅在非 REPL 时才 SystemExit(1)(qgis_cli.py)。README 也把"Agent guidance: Prefer --json"写成显式约定(README.md)。
后端封装:PyQGIS 初始化与 qgis_process 调用
文档的"后端"概念在 utils/qgis_backend.py 中集中落地,这里补充两个文档未展开但实现关键的细节:
- PyQGIS 单例初始化:
ensure_qgis_app()通过_import_qgs_application()导入QgsApplication,_detect_qgis_prefix()优先读QGIS_PREFIX_PATH环境变量,否则从qgis_process/qgis可执行文件向上推前缀,兜底/usr;再setPrefixPath+QgsApplication([], False).initQgis()(utils/qgis_backend.py)。find_qgis_process()在找不到qgis_process时会抛出带安装指引的清晰错误(utils/qgis_backend.py)。 - JSON 契约的稳健解析:
run_process_json()拼qgis_process --json ... [--PROJECT_PATH=...] -- KEY=VALUE...,捕获 stdout 并json.loads;非零返回码或"JSON 模式下却返回非 JSON"都会抛QgisProcessError,并尽量从 payload 的log/results.error里提取人类可读的失败消息(utils/qgis_backend.py)。这正是文档所说"qgis_process自带受支持的 CLI 契约,包含 JSON 输出与算法元数据"的具体兑现。
五、完整实战链路:从建项到导出
把文档的数据模型 + 命令面串起来,就是一条可复制的端到端链路(命令取自 README.md 的真实示例,均默认启用 --json):
# 1. 建项(立即落盘为 .qgz)
cli-anything-qgis --json project new -o demo.qgz --title "Demo" --crs EPSG:4326
# 2. 在项目的旁路 GeoPackage 中建可写图层
cli-anything-qgis --json --project demo.qgz layer create-vector \
--name places --geometry point --field name:string --field score:int
# 3. 用 WKT 加要素,属性按声明类型强转
cli-anything-qgis --json --project demo.qgz feature add \
--layer places --wkt "POINT(1 2)" --attr name=HQ --attr score=5
# 4. 建版式并加元素(未给 --extent 时自动从项目图层推导范围)
cli-anything-qgis --json --project demo.qgz layout create --name Main
cli-anything-qgis --json --project demo.qgz layout add-map \
--layout Main --x 10 --y 20 --width 180 --height 120
cli-anything-qgis --json --project demo.qgz layout add-label \
--layout Main --text "Demo map" --x 10 --y 8 --width 120 --height 10
# 5. 走真实 QGIS 后端导出(native:printlayouttopdf / printlayouttoimage)
cli-anything-qgis --json --project demo.qgz export pdf output.pdf --layout Main --overwrite
cli-anything-qgis --json --project demo.qgz export image output.png --layout Main --overwrite
# 6. 处理算法发现 / 帮助 / 执行(如缓冲)
cli-anything-qgis --json process list
cli-anything-qgis --json process help native:buffer
cli-anything-qgis --json --project demo.qgz process run native:buffer \
--param INPUT=places --param DISTANCE=10 --param SEGMENTS=8 \
--param END_CAP_STYLE=0 --param JOIN_STYLE=0 --param MITER_LIMIT=2 \
--param DISSOLVE=false --param OUTPUT=/tmp/buffer.gpkg
几个参数值得强调(均来自源码取值范围):
layout create的--page-size默认A4、--orientation默认portrait(qgis_cli.py);export pdf支持--dpi(覆盖版式 DPI)、--force-vector/--force-raster、--georeference/--no-georeference(默认开启)、--overwrite(qgis_cli.py);导出前会先save_if_dirty()确保项目落盘,再校验输出文件确实生成(core/export.py);process run的参数用可重复的--param KEY=VALUE传入,内部parse_param_specs会校验格式(core/processing.py)。
REPL 模式下,同样的链路可逐行敲入(不带 --project,状态在长生命周期进程内保持),session status 查看当前项目与修改态,quit 退出——见 README.md 的示例会话。
六、三层测试策略
文档"Testing strategy"给出三层测试要求:1)针对 PyQGIS 助手的直接模块测试;2)针对真实 QGIS 运行时的真实 E2E 流程;3)针对已安装 cli-anything-qgis 可执行文件的子进程测试。该组合同时校验了"库层"与"打包 / 运行时契约"。
仓库中的测试计划 tests/TEST.md 把这三层落成了具体清单:
- 模块层覆盖后端助手(
find_qgis_process/project_path_argument/run_process_json)、项目助手、图层助手(含字段/几何规格解析)、要素助手(含坏布尔/坏属性校验)、版式助手、会话助手; - 真实 E2E 包含三条工作流:scratch 项目→PDF、scratch 项目→PNG、
native:buffer处理透传(校验输出数据集存在、能作为矢量层打开、缓冲要素数为正); - 子进程层针对已安装入口,覆盖
--help、--json process help、project new,以及完整的 PDF/PNG 工作流与"point-only 项目 add-map 回归"。
TEST.md 还记录了真实运行结果:22 passed, 4 warnings in 18.95s,并指出一个已知告警——当前 QGIS 构建中 QgsLayoutItemLabel.setFont() 已弃用,但导出与测试均通过(tests/TEST.md)。这条弃用告警恰好对应 core/layouts.py 中 add_label_item 的 label.setFont(font) 调用,说明文档描述的"窄版式面"与真实实现、测试结果三者相互印证。测试执行命令(含强制走已安装可执行文件的开关)如下:
CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/qgis/tests -v -s --tb=no
七、运行前提与适用边界
综合 README.md 与 setup.py 可确认运行前提:
- 已安装 QGIS,且
qgis_process在PATH上; - 运行本包的同一 Python 能导入 PyQGIS(
qgis.core); - Python 3.10+,依赖
click>=8.0.0与prompt-toolkit>=3.0.0(setup.py)。
快速自检命令:
qgis_process --version
python3 -c "from qgis.core import QgsApplication; print('pyqgis-ok')"
安装(仓库只读,此处仅为说明安装方式):
cd QGIS/agent-harness
python3 -m pip install -e .
若 PyQGIS 来自系统包,普通 venv 可能看不到 qgis 模块,README 建议用系统 site-packages 建 venv 后再 pip install -e .。验证入口 which cli-anything-qgis、cli-anything-qgis --help、cli-anything-qgis --json process help native:printlayouttopdf。
最后重申文档强调的能力边界,避免误用:harness 有意只暴露一个小的版式面(非整套桌面版式系统);处理算法可直接对 shapefile 等数据源路径运行;但当前没有"把任意已存在 shapefile 加入项目"的一等命令——需要这类操作时应直接走 process 子命令或在上游 QGIS 中完成,而非假设 harness 提供了图层导入。
小结
QGIS harness 的设计精髓在于"贴近 QGIS、不重写后端":用 PyQGIS 守住需要跨命令存活的作者态(项目 / 图层 / 要素 / 版式),用 qgis_process --json 复用 QGIS 自带的处理与导出契约,再以 ~/.cli-anything-qgis 的加锁会话文件和全局 --json 输出把整条链路变成对 Agent 友好的可观察状态机。配合"模块 / E2E / 子进程"三层测试,这套窄而可测的能力面(core/ + utils/qgis_backend.py)提供了一个把重型桌面 GIS 软件"代理原生化"的清晰范式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00