用 LibCST 编写 codemod 替换 datetime.utcnow()

举报
yd_229231784 发表于 2026/11/07 07:23:26 2026/11/07
【摘要】 介绍用 LibCST 编写 Python codemod,把 datetime.utcnow() 批量替换为 datetime.now(timezone.utc),自动补充 timezone 导入,并给出 dry-run 与 CI --check 拦截遗漏的做法。

用 LibCST 编写 codemod 替换 datetime.utcnow()

问题场景

项目里散落着 datetime.utcnow()。它返回的是不带时区信息的 naive datetime,和 datetime.now(timezone.utc) 返回的 aware datetime 混用后,容易出现比较、序列化和跨时区展示不一致的问题。手工替换风险在于:文件多、容易漏改,补 from datetime import timezone 时又可能破坏文件头注释、docstring 和 __future__ 导入顺序。

目标很明确:把所有 datetime.utcnow() 改为 datetime.now(timezone.utc),在需要时自动补充 from datetime import timezone,并尽量保留原有格式与注释。

为什么选 LibCST

正则替换很难可靠处理注释、括号空白和 import 位置。LibCST 把源码解析成保留格式信息的 CST,转换后再生成代码,适合做这类小范围 codemod。下面的脚本只处理一种明确写法,避免过度改写。

最小脚本

保存为 tools/codemod_utcnow.py,先安装依赖:

pip install libcst

脚本内容:

#!/usr/bin/env python3
from __future__ import annotations

import argparse
import sys
from pathlib import Path

import libcst as cst


def _attr(value: cst.BaseExpression, name: str) -> cst.Attribute:
    return cst.Attribute(value=value, attr=cst.Name(name))


class RewriteUtcnow(cst.CSTTransformer):
    def __init__(self) -> None:
        self.needs_timezone = False

    def leave_Call(self, original_node: cst.Call, updated_node: cst.Call) -> cst.Call:
        func = original_node.func
        if not (
            isinstance(func, cst.Attribute)
            and isinstance(func.value, cst.Name)
            and func.value.value == 'datetime'
            and isinstance(func.attr, cst.Name)
            and func.attr.value == 'utcnow'
        ):
            return updated_node
        if original_node.args or original_node.keywords:
            return updated_node
        self.needs_timezone = True
        return updated_node.with_changes(
            func=_attr(cst.Name('datetime'), 'now'),
            args=[cst.Arg(_attr(cst.Name('timezone'), 'utc'))],
        )

    def leave_Module(self, original_node: cst.Module, updated_node: cst.Module) -> cst.Module:
        if not self.needs_timezone or _has_top_level_timezone(updated_node):
            return updated_node
        body = list(updated_node.body)
        stmt = cst.parse_statement('from datetime import timezone\n')
        body.insert(_import_index(body), stmt)
        return updated_node.with_changes(body=body)


def _has_top_level_timezone(module: cst.Module) -> bool:
    for stmt in module.body:
        if not isinstance(stmt, cst.SimpleStatementLine):
            continue
        for small in stmt.body:
            if not isinstance(small, cst.ImportFrom):
                continue
            if not (isinstance(small.module, cst.Name) and small.module.value == 'datetime'):
                continue
            for alias in small.names:
                if alias.name.value != 'timezone':
                    continue
                if alias.asname is None or alias.asname.name.value == 'timezone':
                    return True
    return False


def _is_docstring(stmt: cst.BaseStatement) -> bool:
    return (
        isinstance(stmt, cst.SimpleStatementLine)
        and len(stmt.body) == 1
        and isinstance(stmt.body[0], cst.Expr)
        and isinstance(stmt.body[0].value, cst.SimpleString)
    )


def _is_future_import(stmt: cst.BaseStatement) -> bool:
    if not isinstance(stmt, cst.SimpleStatementLine) or not stmt.body:
        return False
    return all(
        isinstance(s, cst.ImportFrom)
        and isinstance(s.module, cst.Name)
        and s.module.value == '__future__'
        for s in stmt.body
    )


def _import_index(body: list[cst.BaseStatement]) -> int:
    i = 0
    if body and _is_docstring(body[0]):
        i = 1
    while i < len(body) and _is_future_import(body[i]):
        i += 1
    return i


def transform(source: str) -> tuple[str, bool]:
    module = cst.parse_module(source)
    new_module = module.visit(RewriteUtcnow())
    out = new_module.code
    return out, out != source


def iter_py(paths: list[str]):
    for raw in paths:
        p = Path(raw)
        if p.is_file() and p.suffix == '.py':
            yield p
        elif p.is_dir():
            yield from sorted(p.rglob('*.py'))


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument('paths', nargs='+')
    parser.add_argument('--check', action='store_true', help='只检查,不写文件')
    parser.add_argument('--dry-run', action='store_true', help='只打印会修改的文件')
    args = parser.parse_args(argv)

    changed_files = []
    parse_errors = []
    for path in iter_py(args.paths):
        source = path.read_text(encoding='utf-8')
        try:
            output, changed = transform(source)
        except cst.ParserSyntaxError as exc:
            parse_errors.append(path)
            print(f'parse error: {path}: {exc}', file=sys.stderr)
            continue
        if not changed:
            continue
        changed_files.append(path)
        if args.check:
            print(f'need rewrite: {path}')
        elif args.dry_run:
            print(f'would rewrite: {path}')
        else:
            path.write_text(output, encoding='utf-8')
            print(f'rewritten: {path}')

    if args.check and changed_files:
        print(f'{len(changed_files)} file(s) need rewrite', file=sys.stderr)
        return 1
    if parse_errors:
        return 2
    return 0


if __name__ == '__main__':
    raise SystemExit(main())

脚本用 leave_Call 定位调用,用 with_changes 保留调用节点周围的大部分空白;leave_Module 只在确实发生过替换且顶层没有可用 timezone 导入时插入新行。导入插入位置会跳过模块 docstring 和 __future__ 导入,减少对文件头结构的破坏。

运行与 dry-run

先看会改哪些文件,不落盘:

python tools/codemod_utcnow.py --dry-run src tests

确认后执行:

python tools/codemod_utcnow.py src tests

建议在独立分支运行,执行后查看 diff,重点检查文件头、docstring 和 import 区域。

在 CI 中用 --check 拦截遗漏

--check 不写文件,只要发现某个文件会被改写就以退出码 1 结束,适合放在 CI 或流水线里:

- run: pip install libcst
- run: python tools/codemod_utcnow.py --check src tests

这样新提交如果又写回 datetime.utcnow(),或者缺少 timezone 导入,流水线会直接失败。也可以把同一命令配到 pre-commit 中,在本地提前拦截。

边界与限制

  • 只匹配 datetime.utcnow() 且参数为空的调用;datetime.datetime.utcnow()、dt.utcnow() 这类别名写法不会命中。
  • 基于语法匹配,不解析名称绑定;如果 datetime 被局部变量遮蔽,脚本无法识别。
  • 空括号内夹注释等极端写法可能无法完全保留,普通行尾注释和缩进会保留。
  • 脚本按 UTF-8 读写;非 UTF-8 文件需先转换或跳过。
  • 插入的是顶层 from datetime import timezone;若项目规范要求 import 排序,可在 codemod 后统一跑格式化工具。

把范围限定清楚、用 --check 固化为检查项,比一次性大规模正则替换更可控。

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。