Hacktoberfest 2026: the issues maintainers tagged for October, open and beginner-friendly. Browse Hacktoberfest issues

fix(layering): storage 层相对导入 server(fenced.py)逃过分层守护——AST 门禁对相对导入整体失明

Closed
#96 2 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
3/5
Estimated time
Half a day
Newbie friendliness
45/100
Issue type
Bug
Clarity
Mostly clear
Activity status
Active
Tech stack
python
Domain
backend, testing

Research direction

Start with visit_ImportFrom in tests/integration/test_layering.py (around lines 101-107), which skips every import with node.level > 0, and with _collect_top_level_imports (around lines 132-160), which has the same gap. The known violation is plaita/storage/fenced.py lines 37-40, which imports ExecutionLeaseError relatively from ..server.execution_lease. Done means the layering gate reports that import as a storage-to-server violation, and the existing 12 tests still pass, checked with pytest tests/integration/test_layering.py. Confirm the fix with the maintainer first, because the issue also asks for a decision on whether to relocate the exception or add a documented exemption.

Written by the indexing model from the issue text.

Description

优先级 P2 · 依赖:无

基线 HEAD = 65f48f2

Summary

分层守护测试 tests/integration/test_layering.py 采集导入时只认绝对导入(node.level == 0),
对同一文件的相对导入完全不采;而 plaita/storage/fenced.py 恰好用相对导入反向依赖了 plaita.server。
该反向依赖今天没有任何运行期后果,但门禁对它失明——同一处依赖一旦被写成绝对导入就会红,写成相对导入则恒绿。

1. 现存违规:storage 层运行时反向导入 server

plaita/storage/fenced.py:37-40(逐字):

try:  # server/__init__ 仅含轻量辅助,无循环导入风险
    from ..server.execution_lease import ExecutionLeaseError
except ImportError:  # pragma: no cover — 极端打包环境退化为 RuntimeError
    ExecutionLeaseError = RuntimeError  # type: ignore[misc,assignment]

运行时可达性(实测,import plaita.storage.fenced 会把 plaita.server 整个拉进 sys.modules):

$ /tmp/plaita-audit-venv/bin/python -c "import plaita.storage.fenced as f, sys; print('plaita.server' in sys.modules, 'plaita.server.execution_lease' in sys.modules, f.ExecutionLeaseError)"
True True <class 'plaita.server.execution_lease.ExecutionLeaseError'>

该依赖被真实使用:plaita/storage/fenced.py:149 抛出这个异常类。

$ grep -n "raise ExecutionLeaseError" plaita/storage/fenced.py
149:            raise ExecutionLeaseError(
2. 门禁对相对导入整体失明

tests/integration/test_layering.py:101-107(逐字)——注释里承诺的
"per-layer prefix mapping elsewhere" 在全文并不存在(grep -n "level" tests/integration/test_layering.py
只命中 :105 与 :156 两处 node.level == 0 判断,无任何把 level > 0 解析成绝对名的逻辑):

        def visit_ImportFrom(self, node: ast.ImportFrom):
            # Only absolute imports resolve to a real module dependency;
            # relative imports stay inside the package and are handled by
            # the per-layer prefix mapping elsewhere.
            if not self.in_type_checking and node.level == 0 and node.module:
                imports.append(node.module)
            self.generic_visit(node)

tests/integration/test_layering.py:46-51(逐字)——_FORBIDDEN 与 _violations_for_layer(:253-265)
只做绝对模块名前缀比对,因此 ..server.execution_lease 这种字符串从未进入比对:

_FORBIDDEN = {
    "foundation": ("plaita.storage", "plaita.server"),
    "event": ("plaita.storage", "plaita.server"),
    "storage": ("plaita.server",),
    "server": (),
}

对应的用例本身是存在的(plaita/server 在 storage 的禁列表里),只是永远采不到这条违规。
tests/integration/test_layering.py:301-306(逐字):

    def test_storage_no_upward_imports(self):
        violations = self._violations_for_layer("storage")
        assert violations == [], (
            "plaita.storage must not import server:\n"
            + "\n".join(f"  - {v}" for v in violations)
        )

门禁假绿(实测):

$ /tmp/plaita-audit-venv/bin/python -m pytest tests/integration/test_layering.py -q
............                                                             [100%]
12 passed in 1.24s

同一处依赖只要改写成绝对导入,就会被现行门禁抓到;保持相对导入则永远抓不到。
对 fenced.py 原文做「仅把该行等价改写为绝对导入、其余字节不动」的对照实验,跑门禁同款采集器:

$ /tmp/plaita-audit-venv/bin/python /tmp/rel_scan_9b.py
relative (real) -> ['__future__', 'contextvars', 'typing']
absolute (equivalent) -> ['__future__', 'contextvars', 'typing', 'plaita.server.execution_lease']
3. 全仓独立扫描:该盲区目前掩盖的违规有且仅有一条

按 node.level 解析相对导入的绝对名后再比对 _FORBIDDEN(脚本 /tmp/rel_scan_9.py):

$ /tmp/plaita-audit-venv/bin/python /tmp/rel_scan_9.py
VIOLATIONS: 1
storage/fenced.py:38 layer=storage -> plaita.server.execution_lease (rel level=2)
4. 其他守卫均不覆盖该层
  • tests/e2e/test_import_layering.py 只扫 plaita/core(:22 CORE_DIR = ... / "plaita" / "core",
    :45 test_no_core_to_server_imports),不覆盖 storage。
  • scripts/ci-gate.sh:62-77 的 layering 相位先跑上面这个 e2e,失败才跑内联脚本,两者都只针对 core。
  • pyproject.toml:108-114 的 [tool.ruff] 只有 :113 一行 select = ["E722", "BLE001"],无 import 禁令;
    仓内无 .importlinter、无 .pre-commit-config.yaml(实测 ls 两者均 No such file)。
  • 结论:AST 集成测试是 storage 层的唯一守卫,而它对该相对导入失明。
5. 契约出处

docs-site/docs/architecture/layering.md:28(逐字):

| `plaita.storage` | foundation + `event`;**禁止** `server` |

docs-site/docs/architecture/layering.md:31(逐字):

**禁止**:foundation / event / storage 反向依赖更上层。这是被 AST 扫描测试守护的硬约束。

AGENTS.md:18(逐字):

依赖方向:`foundation/core` ← `event/storage/node` ← `server`(禁止反向)。
6. 异常回退分支今天是死代码

plaita/server/__init__.py 全文只有 _require_server() 一个纯标准库函数(无任何第三方顶层
import),plaita/server/execution_lease.py:1-17 也只 import 标准库(__future__ / logging /
uuid / typing)。因此 except ImportError 回退当前不可达,今天没有用户可见故障;
flow_worker.py 依赖同一类对象(plaita/server/execution_lease.py:61 逐字
class ExecutionLeaseError(RuntimeError):;plaita/server/flow_worker.py:1677 逐字
except ExecutionLeaseError:;:2747 逐字 except ExecutionLeaseError as exc:),
一旦回退生效,storage 抛出的 RuntimeError 不会被这两处命中,会落到
plaita/server/flow_worker.py:2820 的 except Exception as exc: 兜底重投——
这是未来风险,不是当前故障。本条的实质是守护失效 + 现存违规,而非运行期损失。

影响

  • 受影响的是分层契约与 CI 门禁本身,不是某个部署形态的运行期行为:
    plaita.storage 承诺不依赖 plaita.server(layering.md:28),今天却已经依赖了。
  • 场景一(门禁静默放行):任何作者或 agent 在 plaita/storage/**、plaita/event/**、plaita/core/**
    下新增 from ..server.xxx import yyy 这类相对反向导入,CI 与本地 pytest tests/integration/test_layering.py
    一律 12 passed,没有一条测试会失败。storage 层从此可以悄悄依赖 server 的任意模块,
    分层约束退化为「只对愿意写绝对导入的人生效」。
  • 场景二(契约破裂的真实代价):plaita.storage 被独立使用("库嵌入"形态——只要 storage 与
    redis 后端、不要 server/FastAPI 栈的宿主)时,import plaita.storage.fenced 会连带导入 plaita.server。
    今天这一步恰好只多做一次轻量 import,但它是无告警的层级泄漏:server 层任何时候引入一个
    重量级或会失败的顶层依赖(FastAPI/uvicorn/SQLAlchemy 之类),这条泄漏就会把
    「只想要 storage」的用户一起拖下水,而门禁依然全绿。
  • 场景三(异常身份跨层):ExecutionLeaseError 的定义在 server 层、使用点(fenced.py:149)在 storage 层,
    而消费点(flow_worker.py:1677/:2747)依赖的是同一类对象。回退分支一旦可达,
    storage 抛 RuntimeError 而 worker 只 catch ExecutionLeaseError,失租语义会退化成
    :2820 的兜底重投——同一个执行被反复重投而非按租约语义退出。当前该分支不可达,属潜伏风险。
  • 不修的后果:分层守护继续对相对导入整体失明,且已经存在一处违规被它掩盖;
    后续任何 storage→server 的反向依赖都会静默通过 CI,直到某天以「用户装不起 / 导入即失败」的形式爆发。

建议

  1. 补齐门禁(主要修法,tests/integration/test_layering.py):让 visit_ImportFrom 能解析相对导入。
    最小改法是在采集时把 node.level 与当前文件相对 plaita/ 的包路径拼成绝对模块名后再做前缀比对,
    例如给 _collect_runtime_imports 传入该文件的模块名(_layer_for 已经算过相对路径),
    在 node.level > 0 时按 '.' * node.level 的层级上溯得到 plaita.<pkg>.<module>;
    _collect_top_level_imports(:132-160,同样只判 level == 0)需同步修,否则 foundation 的
    「顶层不得 import event」约束同样可被相对导入绕过。顺带删掉 :102-104 那句已经失真的注释。
    修完后 _violations_for_layer("storage") 必须能报出 storage/fenced.py 的这条违规。
  2. 处置现存违规(plaita/storage/fenced.py:37-40),二选一,都可通过 ci-gate:
    • 消除反向依赖:把 ExecutionLeaseError 的定义(或一个异常基类/协议)下沉到 storage 可依赖的层
      (plaita/core/errors.py 或 storage 自身),server 侧 execution_lease.py 改为复用该定义。
    • 保留现状但显式豁免:若确认这条依赖是设计意图,就把它改成 TYPE_CHECKING + 运行期惰性取用,
      或在门禁里加一条带理由与 issue 链接的白名单条目——而不是靠门禁看不见它。
  3. 验收条件:
    • 新增/修改 tests/integration/test_layering.py 的用例,断言门禁能解析相对导入:
      构造一个「临时把 fenced.py:38 的相对导入等价改写为绝对导入」的对照(或直接对现在这行)
      并断言 _violations_for_layer("storage") 非空 / 或在违规被修掉后断言其为空。
    • 决定性验收:现在跑
      /tmp/plaita-audit-venv/bin/python -m pytest tests/integration/test_layering.py -q 是
      12 passed;修复后,在同样输入下该文件应至少新增一条能捕获相对反向导入的用例,
      且 python /tmp/rel_scan_9.py 的输出由 VIOLATIONS: 1 变为 VIOLATIONS: 0
      (若采纳方案 2 的第一种修法)。
    • 不得通过放宽 _FORBIDDEN["storage"] 或删掉 test_storage_no_upward_imports 来「通过」。
  4. 回归面:改完后跑 bash scripts/ci-gate.sh,确认 layering 相位与既有 12 条用例全绿。

边界

  • 不扩大扫描范围:本条只修「相对导入让门禁失明」这一个缺口,不顺手给 storage/event 增加新的
    分层规则,也不重写 _FORBIDDEN 的表结构。
  • 不重构 server/storage 的异常体系:ExecutionLeaseError 是否应当整体下沉、fenced.py 的
    fencing 语义是否合理,均不在本条范围;本条只要求「门禁能看见这条依赖 + 该依赖被显式处置」。
  • 不修 tests/e2e/test_import_layering.py 的覆盖范围:它只扫 plaita/core 是既有的有意设计
    (scripts/ci-gate.sh 也按此组织),本条不把它扩到 storage;storage 层由集成测试负责。
  • 当前故障不可达:except ImportError 回退分支今天走不到(plaita/server/__init__.py 无第三方
    顶层 import,execution_lease.py 仅依赖标准库),所以本条不承诺「修复后用户可见故障消失」——
    可观测的变化是门禁由假绿变为能报出这条违规。
  • 与既有单的关系:查重 gh issue list -R jeffkit/plaita --state all --limit 100 返回的 33 条
    (#11–#52)中无同根条目;#40(storage 后端缺陷)、#38(僵尸 reaper 的 fence 问题)虽同在
    storage/server 交界,但分别讲 SQLAlchemy/Redis 后端行为与 reaper 判据,与本条「分层门禁失明」
    不是同一根因。
Dominant language
Python
Stars
0
Forks
1
Avg merge
3h 53m
Merged PRs (30d)
3

Getting set up

This project ships no dev container, Dockerfile or contributing guide, so setting up is up to you: start from its README, and see our first-contribution guide for the general steps.

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from jeffkit/plaita

All issues in jeffkit/plaita

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.