首页
/ CLI-Anything QGIS Harness 架构解析:用 PyQGIS + qgis_process 构建有状态的 GIS 命令行代理

CLI-Anything QGIS Harness 架构解析:用 PyQGIS + qgis_process 构建有状态的 GIS 命令行代理

2026-09-05 19:56:51作者:尤峻淳Whitney

本文以 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 源码位置)是 QgsApplicationQgsProjectQgsPrintLayout / 版式元素,以及 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.cppqgsprocess.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 的稳定运行时表面,再包装一个"窄而可测试"的子集。各命令组的来源在文档中被逐一交代:

  • projectlayerfeaturelayout 包装的是需要 live project state 的 PyQGIS 作者面;
  • exportprocess 包装的是稳定的 qgis_process --json 表面,而非在 Python 里重写算法;
  • session 则是 harness 自有的 REPL / history 便捷能力,并非 QGIS 原生特性。

文档也借此解释了当前的能力边界(值得注意的"不做"部分):

  • harness 有意只暴露一个小的版式面,而非整套桌面版式系统;
  • harness 可以直接对 shapefile 及其它数据源路径运行处理算法;
  • harness 当前没有提供"把一个已存在的任意 shapefile 加入项目"的一等命令。

这些边界与真实 CLI 定义一一对应。入口 qgis_cli.pyclick 声明了七个命令组:projectlayerfeaturelayoutexportprocesssession,外加一个 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.pynormalize_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() 保证新建即落盘,并在写入失败时抛出 QgisBackendErrorcore/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_layercore/layers.py)的实际写盘流程非常讲究:先构造一个临时内存图层("memory" provider)用于定义字段,再用 QgsVectorFileWriter.writeAsVectorFormatV3GPKG 驱动把空结构写入旁路 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:4326qgis_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/onfalse/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.jsonqgis_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 默认 portraitqgis_cli.py);
  • export pdf 支持 --dpi(覆盖版式 DPI)、--force-vector / --force-raster--georeference/--no-georeference(默认开启)、--overwriteqgis_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 helpproject new,以及完整的 PDF/PNG 工作流与"point-only 项目 add-map 回归"。

TEST.md 还记录了真实运行结果:22 passed, 4 warnings in 18.95s,并指出一个已知告警——当前 QGIS 构建中 QgsLayoutItemLabel.setFont() 已弃用,但导出与测试均通过(tests/TEST.md)。这条弃用告警恰好对应 core/layouts.pyadd_label_itemlabel.setFont(font) 调用,说明文档描述的"窄版式面"与真实实现、测试结果三者相互印证。测试执行命令(含强制走已安装可执行文件的开关)如下:

CLI_ANYTHING_FORCE_INSTALLED=1 python3 -m pytest cli_anything/qgis/tests -v -s --tb=no

七、运行前提与适用边界

综合 README.mdsetup.py 可确认运行前提:

  • 已安装 QGIS,且 qgis_processPATH 上;
  • 运行本包的同一 Python 能导入 PyQGIS(qgis.core);
  • Python 3.10+,依赖 click>=8.0.0prompt-toolkit>=3.0.0setup.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-qgiscli-anything-qgis --helpcli-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 软件"代理原生化"的清晰范式。

登录后查看全文
热门项目推荐
相关项目推荐