Ctrl K
ComunidadGuías…

tests/test_complexity_ratchet.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""函数复杂度棘轮:函数变复杂必须有人判断过,不许「顺手加一个 if」悄悄堆上去。

2026-09-13 全仓盘点,`src/peach/` 与 `scripts/` 里分支数达到 30 的函数有 42 个,
最大的两个(`library_processing.process_library`、`web_batch.q_ads`)分别是 91 与 85。
它们不是一次写成的,是每次「顺手加一个 if」堆出来的;这个门槛拦的就是那一个 if。

计数是纯 AST 分支数:每个 `if`/`elif`/`for`/`while`/`except`/`with`/`assert`、
每个推导式子句、每个 `and`/`or`、每个 `case` 与条件表达式各计 1,嵌套函数算进
外层。数值只和它自己比,不和别的工具比。

两张表,数都必须与代码一致:

- `BASELINE` 是盘点留下的存量,只能降。函数变简单了就把数改小,降到限值以下就删掉。
- `ACCEPTED` 是判过「就该这么长」的函数,每条带一句理由:顺序流水线、按类型分派、
  逐字段解析这类,拆开只会把一路传下去的状态散进几个只调用一次的函数里。它长了就在
  同一次改动里改数,理由跟着再看一遍还成不成立。

函数超过自己的数时先判断,再二选一。一是拆:拆出去的那块要能独立命名,有自己的前提
和结果,比如 `jav_cover_fetch._download_best`「按档次完整下载,挑第一张静态图」。二是
挪进 `ACCEPTED` 写理由。下面几种不算拆,只是把分支数藏起来,评审一律退回:只转发一个
表达式的包装;把模块函数当参数传给 helper;为了少一个 if 建只有一项的分派表;在注释里
拿这道门槛当设计理由。
"""
import ast
import pathlib
import unittest
from types import MappingProxyType

import peach

REPO = pathlib.Path(peach.__file__).resolve().parents[2]
SOURCE_ROOTS = (REPO / "src" / "peach", REPO / "scripts")

#: 达到这个分支数的函数进入基线;基线之外的函数不许长到这里。
LIMIT = 30

#: 键是 `<仓库相对路径>:<限定名>`,值是当前的分支数,只能往下改。
BASELINE: dict[str, int] = {
    "src/peach/junk.py:_scored_candidates": 53,
    "scripts/localize_performer_names.py:collect": 63,
    "src/peach/web_resource_sync.py:_resource_orphan_plan": 50,
    "src/peach/web_review.py:_review_rows": 38,
    "src/peach/fanbox.py:normalize_fanbox_post": 59,
    "scripts/merge_duplicate_identities.py:collect": 50,
    "src/peach/web_review.py:_attach_review_asset_context": 46,
    "src/peach/web_review.py:w_review_decision": 41,
    "scripts/harvest_social_avatars.py:run": 45,
    "scripts/fetch_studio_avatar_candidates.py:main": 44,
    "src/peach/web_catalog.py:q_item": 44,
    "src/peach/web_follow.py:_follow_author_rows": 35,
    "src/peach/taste_history.py:_taste_analysis": 40,
    "src/peach/web_catalog.py:catalog_filter": 40,
    "scripts/audit_domain_codes.py:collect": 39,
    "scripts/harvest_social_avatars.py:harvest_entity": 37,
    "scripts/merge_duplicate_identities.py:collect_repeated_projections": 34,
    "src/peach/fc2_similarity.py:media_evidence": 34,
    "scripts/clean_names.py:run": 33,
    "src/peach/studio_icons.py:icon_row": 32,
    "scripts/find_ads.py:find_candidates": 31,
    "scripts/rehome_unknown_jav.py:build_plan": 31,
    "src/peach/resource_identification.py:ingest_results": 30,
}

#: 判过就该这么长的函数:`(分支数, 为什么拆开更差)`。键与 `BASELINE` 同形、不重叠。
ACCEPTED: dict[str, tuple[int, str]] = {
    "src/peach/api.py:create_app": (
        40, "应用工厂:按启动顺序构造数据库、各项后台服务(含可选分享)与路由并接成一个 app,每个可注入的"
            "依赖各带一个缺省;挂载探测按配置状态启动。拆开会把同一条构造顺序分散到几处"),
    "src/peach/library_processing.py:process_library": (
        48, "顺序流水线:垃圾检查、媒体属性、本地资料、本地预览、联网采集、写候选、收尾共用一份进度状态"
            "与问题记录;来源扫描、资产查询与外部会话分别归 `_scan_sources`、"
            "`_collection_rows`、`_RemoteSession`,剩下的是任务编排、逐项目资料处理与指定范围的存量重新核查"),
    "src/peach/jav_cover_fetch.py:run": (
        50, "批处理的主循环:每个番号依次量本机尺寸、择优、比较、落盘、记日志,统计、日志行"
            "和出错后换新的连接池在整轮里共用,逐段拆开只是把这几样来回传"),
    "scripts/probe.py:run": (
        41, "媒体批处理:选择允许读取的资产、检查垃圾、并行探测、串行提交与重判共用日志、"
            "停止标志和磁盘闸门;提交后的资产列表必须跨缓冲批次保留,拆散会割裂中止时的收尾"),
    "scripts/sheets.py:run": (
        43, "媒体批处理:按来源选择资产并检查垃圾、并行抽帧、串行登记共用日志、计数和停止标志;"
            "已有产物复用与磁盘闸门属于同一工作循环,拆开会把退出与失败统计分散到多处"),
}

#: 一句理由至少要说清「是哪种形状、拆开差在哪」,短于这个长度的多半只是占位。
MIN_REASON = 20

BRANCHES = (ast.If, ast.For, ast.AsyncFor, ast.While, ast.ExceptHandler,
            ast.With, ast.AsyncWith, ast.Assert, ast.IfExp, ast.match_case)


def complexity(node: ast.AST) -> int:
    """`node` 的分支数加一:一条直线的函数是 1。"""
    total = 1
    for child in ast.walk(node):
        if isinstance(child, BRANCHES):
            total += 1
        elif isinstance(child, ast.comprehension):
            total += 1 + len(child.ifs)
        elif isinstance(child, ast.BoolOp):
            total += len(child.values) - 1
    return total


def functions(path: pathlib.Path):
    """`path` 里每个函数与方法的 `(限定名, 分支数)`;嵌套函数并进外层,不单列。"""
    tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))

    def walk(body, prefix):
        for node in body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                yield prefix + node.name, complexity(node)
            elif isinstance(node, ast.ClassDef):
                yield from walk(node.body, prefix + node.name + ".")

    yield from walk(tree.body, "")


def survey() -> dict[str, int]:
    """当前树里每个函数的分支数,键与 `BASELINE` 同形。"""
    found = {}
    for root in SOURCE_ROOTS:
        for path in sorted(root.rglob("*.py")):
            relative = path.relative_to(REPO).as_posix()
            for name, value in functions(path):
                found[f"{relative}:{name}"] = value
    return found


def recorded() -> dict[str, int]:
    """两张表里记下的数合在一起。"""
    return {**BASELINE, **{key: value for key, (value, _reason) in ACCEPTED.items()}}


class ComplexityRatchetTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.current = MappingProxyType(survey())

    def test_the_counter_counts_the_shapes_it_names(self):
        source = (
            "def f(items):\n"
            "    for item in items:\n"
            "        if item and item.ok or item.forced:\n"
            "            try:\n"
            "                pass\n"
            "            except ValueError:\n"
            "                pass\n"
            "    return [x for x in items if x]\n"
        )
        node = ast.parse(source).body[0]
        # 1 + for + if + and + or + except + 推导式 + 推导式的 if
        self.assertEqual(complexity(node), 8)

    def test_no_function_grew_past_its_recorded_count(self):
        current, limits = self.current, recorded()
        grown = sorted(
            f"{key}: {value}(记录 {limits.get(key, LIMIT - 1)})"
            for key, value in current.items()
            if value > limits.get(key, LIMIT - 1))
        self.assertEqual(grown, [],
                         "这些函数的分支数超过了记录。拆出能独立命名的一块,或判过它就该"
                         "这么长后挪进 ACCEPTED 写理由;转发包装、把函数当参数传、单项分派表"
                         "都不算拆:\n  " + "\n  ".join(grown))

    def test_the_recorded_counts_match_the_code(self):
        current = self.current
        stale = sorted(
            f"{key}: 现在 {current.get(key, '不存在')},记录 {value}"
            for key, value in recorded().items()
            if key not in current or current[key] < value)
        self.assertEqual(stale, [],
                         "记录落后于代码:函数变简单或删掉了就把这里改小或删掉,"
                         "记录不能替将来的增长留余量:\n  " + "\n  ".join(stale))

    def test_the_tables_hold_only_functions_at_or_over_the_limit(self):
        below = sorted(key for key, value in recorded().items() if value < LIMIT)
        self.assertEqual(below, [], f"低于 {LIMIT} 的函数不该进表:{below}")

    def test_an_accepted_function_says_why_splitting_is_worse(self):
        self.assertEqual(sorted(set(BASELINE) & set(ACCEPTED)), [], "一个函数只能在一张表里")
        thin = sorted(key for key, (_value, reason) in ACCEPTED.items()
                      if len(reason.strip()) < MIN_REASON)
        self.assertEqual(thin, [], f"这些条目的理由不足 {MIN_REASON} 字:{thin}")


if __name__ == "__main__":
    unittest.main()