目录

15 · account.py:账户名的语法、遍历与文件系统映射

核对基线 · 范围 · 依赖

核对基线:beancount 仓库 commit 97472138(2026-08-22)。路径相对仓库根目录,引用格式 文件:起-止行 (名称),行号已逐条核对。 本篇范围beancount/core/account.py(273 行)与 beancount/core/account_test.py(186 行)。 上游依赖:标准库 osreunicodedataos.pathtypingAny/Callable/Iterable/Iterator),以及第三方 regexpyproject.toml:33 声明 regex >=2022.9.13)。不依赖 beancount 内部任何模块。 下游使用者:17 个非测试文件导入本模块;beancount/core/account_types.py:20 建立在它之上,beancount/core/data.py:21 直接从它引入 has_componentbeancount/parser/grammar.py:126 复用 sepACC_COMP_NAME_RE 拼装带配置根名的账户正则。

1. 模块解决什么问题

beancount 里账户不是对象,就是一个字符串。模块开头的 docstring 写明这一点:

These account objects are rather simple and dumb; they do not contain the list
of their associated postings. This is achieved by building a realization; see
realization.py for details.

beancount/core/account.py:1-6Account = strbeancount/core/account.py:25)只是类型别名,没有包装类,"账户"的全部结构信息只剩字符串本身的形状:用冒号分层的组件序列 Assets:US:BofA:Checking。本模块负责三件事——定义这个形状的语法(正则)、在这个形状上做切分与拼接的纯函数、把文件系统目录树映射回账户名。

2. 结构一览

名称 位置 作用
Account = str beancount/core/account.py:25 类型别名,账户就是字符串
sep = ":" beancount/core/account.py:29 分层分隔符,被 account_types.pygrammar.py 复用
ACC_COMP_TYPE_RE / ACC_COMP_NAME_RE beancount/core/account.py:32-38 首段与后续段的组件正则,用 Unicode 属性类
ACCOUNT_RE beancount/core/account.py:40-41 完整账户正则,带同步锚点 {5672c7270e1e}
TYPE = "<AccountDummy>" beancount/core/account.py:44-46 Custom 指令里区分"账户名"与"普通字符串"的哨兵
is_valid_root / is_valid_leaf / is_valid beancount/core/account.py:49-84 三个语法校验,全部用 regex.fullmatch
join / split beancount/core/account.py:87-106 组件与账户名互转
parent / leaf / sans_root / root beancount/core/account.py:109-162 层级切分
has_component beancount/core/account.py:165-176 整段匹配,不做子串匹配
commonprefix beancount/core/account.py:179-192 一组账户的共同父账户
walk beancount/core/account.py:195-220 目录树 → 账户名的生成器
parent_matcher / parents beancount/core/account.py:223-247 父子判定谓词、向上迭代器
class AccountTransformer beancount/core/account.py:250-273 冒号与替代分隔符的双向转换

3. 账户名的语法

3.1 两级组件正则

# Regular expression string that matches valid account name components.
# Categories are:
#   Lu: Uppercase letters.
#   L: All letters.
#   Nd: Decimal numbers.
ACC_COMP_TYPE_RE = r"[\p{Lu}][\p{L}\p{Nd}\-]*"
ACC_COMP_NAME_RE = r"[\p{Lu}\p{Nd}][\p{L}\p{Nd}\-]*"

# Regular expression string that matches a valid account. {5672c7270e1e}
ACCOUNT_RE = r"(?:{})(?:{}{})+".format(ACC_COMP_TYPE_RE, sep, ACC_COMP_NAME_RE)

beancount/core/account.py:32-41。规则拆成三条:首段(账户类型)必须以 Unicode 大写字母开头;后续段可以以大写字母或十进制数字开头;两者的后续字符都是"任意字母 + 数字 + 连字符"。ACCOUNT_RE 里的 + 要求至少一个 : 后续段,因此 Assets 单独一个词不是合法账户名,beancount/core/account_test.py:19-21AssetsInvalidOther 三例锁定这条。

正则用的是 Unicode 属性类而非 [A-Z],非 ASCII 大写字母同样能出现在首字符位置;beancount/core/account_test.py:24-25 用大小写混合的反例(Assets:US:RBS:checkingAssets:us:RBS:checking)锁定首字母必须大写这一约束。末位不受限制:[\p{L}\p{Nd}\-]* 允许连字符结尾,Assets:A- 通过校验。

{5672c7270e1e} 是人为放置的同步锚点,全仓库仅两处:beancount/core/account.py:40 的定义处,和 beancount/parser/grammar.py:124 的注释 "This code is kept in sync with {5672c7270e1e}"。后者在 valid_account_regexp(options)beancount/parser/grammar.py:109-127)里把 ACCOUNT_RE 的第一项替换成 name_assets 等五个选项配置出的实际根名,用于解析阶段校验根账户是否在允许集合内:

return regex.compile(
    "(?:{})(?:{}{})+".format("|".join(names), account.sep, account.ACC_COMP_NAME_RE)
)

valid_account_regexp 生成的表达式不带结尾锚点。调用点 Builder.account()beancount/parser/grammar.py:276-293)用 self.account_regexp.match(account) 而非 fullmatch:以配置的五个根名重编译后实测,Assets:Cash💰match() 只需吃到 Assets:Cash 前缀即成功,fullmatch() 则为 False——语法层的校验不是全串匹配,合法前缀后接任意合法多字节 UTF-8 尾巴就能通过。校验失败时 account() 只把 ParserError 追加进 self.errors,随后仍执行 self.accounts.setdefault(account, account)beancount/parser/grammar.py:293),原字符串被当作合法账户名缓存并原样返回。commit 8b41d3a1(2018-03-26,Fixed #245,CHANGES:1063-1066)同步过词法器与 Python 侧的正则定义,但未改变 .match() 调用方式。

3.2 为什么用 regex 而不是 re

标准库 re 不支持 \p{...} 属性类。2023-09-18 的 commit 1dc1639eis_validre.match 换成 regex.match,删掉此前的变通方案 beancount.utils.regexp_utils.re_replace_unicode。模块因此同时 import 两个正则库(beancount/core/account.py:14,22):is_valid_root/is_valid_leaf/is_validregex,而 has_componentbeancount/core/account.py:176)与 parent_matcherbeancount/core/account.py:232)用标准库 re——这两处匹配的是字面量组件,不需要属性类。

2024-11-09 的 commit b5346232regex.match("{}$".format(ACCOUNT_RE), string) 改成 regex.fullmatch(ACCOUNT_RE, string)$ 在 Python 正则里匹配"字符串末尾或末尾换行之前",fullmatch 不允许尾随换行,改完之后 is_valid("Assets:US\n")False

3.3 三个校验函数的分工

def is_valid_root(string: Account) -> bool:
    return isinstance(string, str) and bool(regex.fullmatch(ACC_COMP_TYPE_RE, string))

def is_valid_leaf(string: Account) -> bool:
    return isinstance(string, str) and all(
        regex.fullmatch(ACC_COMP_NAME_RE, p) for p in string.split(":")
    )

def is_valid(string: Account) -> bool:
    return isinstance(string, str) and bool(regex.fullmatch(ACCOUNT_RE, string))

beancount/core/account.py:49-84。三者都先 isinstance(string, str),非字符串返回 False 而不抛异常。is_valid_root 校验单个首段,is_valid_leaf 校验可自带冒号的尾部片段(US:Bank:2014 通过),is_valid 校验完整账户名。is_valid_root/is_valid_leaf 是 2024-11-09 commit 07406fd5(Fixes #754)为选项校验新增的,唯一调用点 beancount/parser/options.py:131,146name_assets 等五个根名选项走 root 校验,account_previous_earnings 等七个叶子名选项走 leaf 校验。

is_valid_leaf 内部用字面量 string.split(":"),未走模块级的 sepbeancount/core/account.py:71),详见 8 节。

3.4 与词法器、与 account_types 的三份规则

账户语法在仓库里有三份实现,彼此不完全等价。

第一份是 flex 词法器(beancount/parser/lexer.l:129-130,272-274):

ACCOUNTTYPE     ([A-Z]|{UTF-8-ONLY})([A-Za-z0-9\-]|{UTF-8-ONLY})*
ACCOUNTNAME     ([A-Z0-9]|{UTF-8-ONLY})([A-Za-z0-9\-]|{UTF-8-ONLY})*

它按字节工作,UTF-8-ONLYbeancount/parser/lexer.l:126)是"任何合法的多字节 UTF-8 序列"。词法层能把 资产:现金ärger:US 这类形状识别成 ACCOUNT token;结合 3.1 节的 .match() 边界,其放行的多字节尾巴未必逐段校验。

第二份是本模块 ACCOUNT_RE。第三份是 beancount/core/account_types.py:96 (is_root_account)

return bool(account_name) and bool(re.match(r"([A-Z][A-Za-z0-9\-]+)$", account_name))

它用标准库 re、纯 ASCII 字符类、+ 要求至少两个字符、match$ 而非 fullmatch"A"is_valid_rootTrueis_root_accountFalse"Assets\n" 反过来,is_valid_rootFalseis_root_accountTrue。同文件 is_account_typebeancount/core/account_types.py:72-81)把 account_type 未经 re.escape() 直接插进表达式,风险见 8 节。

4. 层级切分函数

函数 位置 实现 空串输入
join(*components) beancount/core/account.py:87-95 sep.join(components) join()""
split(name) beancount/core/account.py:98-106 name.split(sep) split("")[""]
parent(name) beancount/core/account.py:109-122 去掉最后一段 parent("")Noneparent("Expenses")""
leaf(name) beancount/core/account.py:125-134 取最后一段 leaf("")None
sans_root(name) beancount/core/account.py:137-150 去掉第一段 sans_root("")Nonesans_root("Assets")""
root(n, name) beancount/core/account.py:153-162 取前 n 段 root(0, name)""
parents(name) beancount/core/account.py:236-247 自身及各级父账户的生成器 list(parents(""))[]
commonprefix(accounts) beancount/core/account.py:179-192 按组件求公共前缀 commonprefix([""])""
has_component(name, comp) beancount/core/account.py:165-176 整段匹配 见 4.2 节
parent_matcher(name) beancount/core/account.py:223-233 返回判定谓词 见下

空串处理不统一。parentleaf 有直接测试锁定空串行为:account.parent("")account.leaf("") 均为 Nonebeancount/core/account_test.py:47-57)。sans_root 也在源码里判空返回 Nonebeancount/core/account.py:150),但 beancount/core/account_test.py:59-62 只断言了三个非空输入,sans_root("")None 结果无测试覆盖,只能从实现推出。splitjoinroot 不判空,直接返回空串或 [""]。返回类型注解相应写成 Account | Nonebeancount/core/account.py:109,125,137),而 rootAccountbeancount/core/account.py:153)。root 不做参数校验:负数按 Python 切片语义生效,root(-1, "A:B:C") 得到 "A:B"

parentleafsans_root 三者都有 assert isinstance(account_name, str)beancount/core/account.py:117,133,148),传 NoneAssertionErrorsplit 没有断言,传 NoneAttributeError

commonprefix 先把每个账户切成组件列表,再借 os.path.commonprefix 对列表求前缀,源码注释(beancount/core/account.py:188-190):"the os.path.commonprefix() function just happens to work here"。逐组件而非逐字符是关键:commonprefix(["Assets:US", "Assets:USA"]) 得到 "Assets" 而不是 "Assets:US"。全仓库无生产调用点,仅 beancount/core/account_test.py:88-104 使用。

4.1 parent_matcher:边界必须落在组件上

pattern = re.compile(r"{}($|{})".format(re.escape(account_name), sep))
return lambda s: bool(pattern.match(s))

beancount/core/account.py:232-233($|:) 保证匹配止于账户名末尾或冒号处。这一形式来自 2019-01-29 的 commit 18429bea(Fixed #362,CHANGES:469-471),此前写的是 r'{}\b'.format(...)——\b 是词边界,Assets:Bank:Checking-OldChecking 后面的 - 构成词边界,于是误判为子账户。CHANGES 记录该 bug "was causing a rare (but important) failure in how Pad balances were calculated"。beancount/core/account_test.py:108-113 同时锁定 CheckingOldChecking-Old 两种否定情形。

调用点是 beancount/ops/pad.py:68beancount/ops/balance.py:81,判断账户是否落在被断言的子树内。

2024-12-23 的 commit 0f879811("enable mypy")把 parent_matcherreturn re.compile(...).match 改成 lambda s: bool(pattern.match(s)),让返回类型显式是 bool 而非 Match | None,行为不变。

4.2 has_component:两次修复之间的往返

当前实现:

return bool(re.search("(^|:){}(:|$)".format(re.escape(component)), account_name))

beancount/core/account.py:176。docstring 明确语义是整段匹配:"a component name must be whole, that is NY is not in Expenses:Taxes:StateNY"(beancount/core/account.py:173-174)。beancount/core/account_test.py:73-79CreditCard 两个否定例锁定它不做子串匹配。

这一行在基线之前的两个提交里被改了两次,两个提交都在 2026-08-22 同一天:

2019-02-26 的 commit 07efa286(Fixed #376,CHANGES:449-451 "Coerce implicit boolean to true boolean")把 has_component 的返回值从 re.search(...)(返回 Match 对象或 None)改成 bool(re.search(...));此前靠真值判断隐式当布尔用。

两种写法在正常输入上等价,但 component 自身含冒号时不等价:has_component("Liabilities:US:Credit-Card", "US:Credit-Card") 在 split 版本为 False(不是任何单个组件),在当前转义正则版本为 True(能在原串里搜到)。测试未覆盖这个差异。空组件也有独立行为:has_component("", "")True

has_componentaccount. 前缀下没有调用点,它被 beancount/core/data.py:21from beancount.core.account import has_component 引入,供 data.has_entry_account_componentbeancount/core/data.py:779)判断一条 entry 的任一 posting 是否含某组件。

5. walk:目录树反推账户名

for root, dirs, files in os.walk(root_directory, followlinks=followlinks):
    dirs.sort()
    files.sort()
    relroot = root[len(root_directory) + 1 :]
    account_name = relroot.replace(os.sep, sep)
    # The regex module does not handle Unicode characters in decomposed
    # form. Python uses the normal form for representing string. However,
    # some filesystems use the canonical decomposition form.
    # See https://docs.python.org/3/library/unicodedata.html#unicodedata.normalize
    account_name = unicodedata.normalize("NFKC", account_name)
    if is_valid(account_name):
        yield root, account_name, dirs, files

beancount/core/account.py:209-220。把 documents 选项指向的目录树映射成账户名:相对路径里的 os.sep 换成 :,不合法的目录(含根目录本身,relroot 为空串)不被 yield。函数只是不 yield 当前目录,不改写 dirs 列表:非法目录名(如测试夹具里的 otherdir)仍留在父目录的 dirs 里,os.walk 仍会下降进它的子树(beancount/core/account_test.py:125-127,144-149)。dirs.sort()/files.sort()(来自 2014-12-08 的 commit 76b91a79)让遍历顺序稳定,dirs 就地排序同时影响 os.walk 后续下降顺序。

unicodedata.normalize("NFKC", ...) 那一行是 2023-09-18 的 commit 45ccf0a1(Fixes #750)加的,注释给出了理由与文档链接:regex 模块不处理分解形式的 Unicode 字符,而部分文件系统(如 macOS 的 HFS+)以规范分解形式(NFD)存储文件名。同一提交往测试夹具里加了 "root/Assets/Cäsh/2023-08-18.test.pdf"beancount/core/account_test.py:130,注释 "Unicode directory name."),断言里期望 ("/Assets/Cäsh", "Assets:Cäsh", [], [...])beancount/core/account_test.py:141)。NFD 形式(C+a+U+0308+sh)的目录名会先被 is_valid 拒绝,归一化后才通过。NFKC 还做兼容折叠:全角 AssetsAssets1,不同目录名可能折叠出同一账户名。

followlinks 参数默认 True,来自 2025-01-23 的 commit c8803e3d(Fixed #933);同月的 7c69601a 先把它作为可选参数引入、默认 False,四天后改成默认跟随。walk 把参数原样转给 os.walk,自己不维护已访问目录集合,os.walk 在此模式下也不做环检测,指向祖先目录的符号链接会使遍历无法自行终止。

relrootroot[len(root_directory) + 1:] 硬切一个字符,隐含"root_directory 不带尾随分隔符"的前提。传入带尾斜杠的路径时每个 relroot 都多切一个字符,通常导致 is_valid 全部失败、目录被过滤,但结果不保证为空:若某一级目录名去掉首字符后仍是合法根名,会被错误映射——root_directory/tmp/root/ 时,/tmp/root/XAssets/US 被映射为看似合法的 Assets:US,真正的 /tmp/root/Assets/US 反而因切成 ssets/US(首字母小写)被过滤掉。两处调用点是否触发,见 8 节表格。

6. AccountTransformerTYPE

class AccountTransformer:
    """This is used to support Win... huh, filesystems and platforms which do not
    support colon characters."""

    def __init__(self, rsep: str | None = None):
        self.rsep = rsep

    def render(self, account_name: Account) -> str:
        return account_name if self.rsep is None else account_name.replace(sep, self.rsep)

beancount/core/account.py:250-265rsepNonerender/parse 都是恒等函数(beancount/core/account_test.py:174-178 (test_noop))。它来自 2017-06-25 的 commit e16254ce,为 bean-web/bean-bake 提供一项命令行选项:CHANGES:1316-1317 记成单数 --no-colon,但同一提交里 beancount/web/web.py:1177 定义的实际参数是复数 --no-colonsbeancount/web 目录已不存在,全仓库仅 beancount/core/account_test.py:163-178 引用。

TYPE = "<AccountDummy>"beancount/core/account.py:44-46)是一个哨兵字符串。Custom 指令的值被表示成 (value, dtype) 对,账户名与普通字符串在解析后都是 str,靠 dtype 区分——这套表示、grammar.y 的 ACCOUNT 分支、printer.py 的身份判断均出自 2016-04-09 的 a9f9ac89TYPE 当时取值 'Account',值对是普通二元组);次日 34c1eb9c(2016-04-10)把 TYPE 改成 '<AccountDummy>'、二元组换成 ValueType namedtuple,写入 CHANGES:2192-2207。C 语法层通过 PyImport_ImportModule + PyObject_GetAttrString 取到这个模块属性本身(beancount/parser/grammar.y:786-788),因此拿到的是同一个对象。消费侧两种写法并存:beancount/parser/printer.py:412dtype is account.TYPEbeancount/core/realization.py:312custom_value.dtype == account.TYPE。字符串驻留、跨编译单元的字面量是否共享对象属于 CPython 实现细节,不是语言保证;解析器生成的 TYPE 对象身份及 printer.py 该分支已由 beancount/parser/printer_test.py:253-260 的 Custom 往返测试间接覆盖:若重新解析出的 dtype 不是同一个 TYPE 对象,printer.py 会把账户值当字符串加引号,往返比较即失败。

7. 设计决策与理由

决策 理由 证据
账户是裸字符串而非对象 不携带 posting 列表,关联关系交给 realization 树 beancount/core/account.py:1-6,25
首段与后续段用两条不同正则 首段是账户类型必须是字母;后续段允许纯数字(年份、账号) beancount/core/account.py:37-38CHANGES:1063-1066,1274-1276
regex + Unicode 属性类 标准库 re 不支持 \p{Lu};账户名需支持非 ASCII 字母 beancount/core/account.py:22;commit 1dc1639eb7114f76
ACCOUNT_RE 要求至少两段 单个大写词在词法层是 flag/CAPITAL token,两段以上才无歧义 beancount/core/account.py:41beancount/parser/lexer.l:233-243CHANGES:1228-1231
正则定义处放同步锚点 {5672c7270e1e} 语法层要用配置根名替换首项,两处必须一起改 beancount/core/account.py:40beancount/parser/grammar.py:124
校验函数先查 isinstance(str) 非字符串返回 False 而不抛异常,可直接用于选项值校验 beancount/core/account.py:58,70,84beancount/parser/options.py:131,146
fullmatch 而非 match + $ $ 允许尾随换行,fullmatch 不允许 commit b5346232
parent_matcher($|:) 而非 \b 连字符构成词边界,\b 会把 Checking-Old 误判为 Checking 的子账户 commit 18429beaCHANGES:469-471
has_component 对组件做 re.escape 组件字符串可能来自用户输入,含元字符时不应崩溃 commit 97472138beancount/core/account_test.py:81-86
walk 中做 NFKC 归一化 部分文件系统以 NFD 存储文件名,regex 不处理分解形式 beancount/core/account.py:214-218;commit 45ccf0a1
walk 只跳过非法目录、不裁剪 dirs 文档目录树里混有非账户目录(如 otherdir)是常态,函数只负责识别哪些目录是账户 beancount/core/account.py:209-220beancount/core/account_test.py:125-127,144-149
TYPE 用哨兵字符串而非新类型 让账户名在 Custom 指令里保持 str,改类型代价太大 beancount/core/account.py:44-46CHANGES:2202-2205

8. 行为细节与边界

现象 后果 证据
ACC_COMP_TYPE_RE 末位无限制 Assets:A-A-:B 通过 is_valid beancount/core/account.py:37-38
is_valid_root("A")Trueaccount_types.is_root_account("A")False 两份根账户规则不等价:后者要求至少两字符、纯 ASCII、且用 $ 而非 fullmatch beancount/core/account.py:58beancount/core/account_types.py:96
valid_account_regexp() 生成的表达式配 .match() 而非 .fullmatch() 语法层校验不是全串匹配,前缀合法、尾部为任意合法多字节 UTF-8 的字符串(如 Assets:Cash💰)能通过 beancount/parser/grammar.py:109-127,286
account_types.is_account_type 不转义 account_type 元字符可能改变匹配语义而不报错(.*);语法不完整的元字符组合(([A)抛 re.error beancount/core/account_types.py:72-81
is_valid_leaf 硬编码 ":" 与同文件 split() 使用 sep 的写法不一致 beancount/core/account.py:71,106
is_valid_leaf 接受多段字符串 is_valid_leaf("US:Bank:2014")True,名字里的 "leaf" 指"账户名的尾部"而非单段 beancount/core/account.py:70-72
空串在各函数返回值不统一 parent/leaf 返回 None 且有测试;sans_root("")None 但无测试覆盖;split 返回 [""]join()/root(0,·) 返回 "" beancount/core/account.py:118-119,134,150beancount/core/account_test.py:51,57,59-62
parent("Expenses") 返回 "" 而非 None 顶层账户的父是空串;parents()while current_account 的真值判断在此停住 beancount/core/account.py:122,245beancount/core/account_test.py:50,115-118
root 不校验 num_components 负数按切片语义生效,root(-1, "A:B:C")"A:B" beancount/core/account.py:162
parent/leaf/sans_rootassert 做类型检查 python -O 下断言被剥离,None 输入改为抛 AttributeError 或返回 None beancount/core/account.py:117,133,148
has_component 的组件可含冒号并跨段匹配 has_component("Liabilities:US:Credit-Card", "US:Credit-Card")Truea75c419c 的 split 版本为 False beancount/core/account.py:176
has_component("", "")True (^|:)(:|$) 在空串上由 ^$ 同时命中 beancount/core/account.py:176
parent_matcher("") 生成的谓词几乎恒 False 模式退化为 ($|:) 且用 match,只有空串与以冒号开头的串为 True beancount/core/account.py:232-233
walk 对带尾随分隔符的 root_directory 通常产出空序列,但不保证 relroot 多切一个字符;若切掉首字符后仍是合法根名会产出错误映射(如 XAssets/USAssets:US);ops/documents.py 调用前先 path.normpath 故不会触发;scripts/directories.py 的两个函数本身不规范化路径,仅 doctor CLI 用 click.Path(resolve_path=True) 约束了输入,直接调用这两个函数仍可能触发 beancount/core/account.py:212beancount/ops/documents.py:52-58beancount/scripts/directories.py:41,53-69beancount/scripts/doctor.py:191-209
walk 用 NFKC 而非仅 NFC 不只合成分解字符,也做兼容折叠:全角 A1,目录名到账户名不是一一映射 beancount/core/account.py:218
walk 默认跟随符号链接且不去重 followlinks=True 直接转给 os.walk,没有已访问目录集合;os.walk 本身在此模式下也不做环检测 beancount/core/account.py:195-209
walk 归一化只作用于账户名,不作用于 root 产出元组里的 root 保留文件系统原始形式(可能是 NFD),账户名是 NFC/NFKC beancount/core/account.py:212-220
AccountTransformer 不防冲突 rsep 若出现在账户名中,parse(render(x)) 不还原 beancount/core/account.py:263-273
TYPEis 比较依赖对象来源一致 C 层与 Python 层都从模块属性取同一对象,is 比较成立;细节及测试覆盖见 6 节 beancount/core/account.py:46beancount/parser/grammar.y:786-788beancount/parser/printer.py:412
无生产调用点的函数 leafrootcommonprefixparentshas_component(以 account. 前缀调用的形式)、AccountTransformer 在非测试代码中均无调用;has_componentbeancount/core/data.py:21 的直接引入被使用 全仓库 grep

9. 测试锁定了什么

beancount/core/account_test.py 声明 16 个 test_* 方法,分四个类。

类 / 测试 行号 锁定的行为
TestAccount.test_is_valid 13-25 至少两段;连字符合法;*& 非法;后续组件小写开头非法
test_account_join / test_account_split 27-45 单组件、零组件、空串三种边界
test_parent / test_leaf / test_sans_root 47-62 逐级上溯;parent("")leaf("")Nonesans_root("Assets")""
test_root 64-71 n=0n=5,超出组件数返回全名
test_has_component 73-79 整段匹配;CreditCard 不匹配 Credit-Card
test_has_component_special_chars 81-86 Foo)(a.ba+b 返回 False 而非崩溃
test_commonprefix 88-104 逐组件求前缀;无共同前缀返回 ""
TestAccountCoreOnly.test_parent_matcher 108-113 自身与子账户为真;CheckingOldChecking-Old 为假
test_parents 115-118 返回生成器;含自身,到顶层为止
TestWalk.test_walk 121-160 目录与文件排序;跳过 otherdir;空目录 Liabilities/US/Bank 仍产出;Cäsh 的 Unicode 目录名
TestAccountTransformer 163-178 render/parse 往返;rsep=None 为恒等

test_is_valid 本身没有任何数字开头组件的用例;账户组件允许数字开头这一规则由词法层的 beancount/parser/lexer_test.py:327-345 (test_account_names_with_numbers)Assets:Vouchers:99RanchAssets:99Test 覆盖,account.is_valid 没有对应的直接测试。

文件末尾有一段条件删除(beancount/core/account_test.py:181-186):以 __main__ 方式运行时,若 account 模块没有 __copyright__ 属性,就删掉 TestAccountTransformerTestWalkTestAccountCoreOnly 三个类,只留 TestAccount 的 10 个测试,来自 2020-09-20 的 commit b6974795 "Unified testing from core to apply to ccore.",供 ccore 复用。

未覆盖的行为:is_valid_root/is_valid_leaf 无单元测试(仅 options_test.py 间接覆盖);sans_root("")None 结果;join/split 的非字符串输入;parent(None) 等断言分支;has_component 的含冒号组件与空组件;parent_matcher("")walkfollowlinks、尾随分隔符与 NFKC 兼容折叠;AccountTransformerrsep 冲突;手工构造的、与 account.TYPE 等值但非同一对象的字符串(解析器生成的 TYPE 对象身份及 printer.py 该分支已由 beancount/parser/printer_test.py:253-260 的往返测试间接覆盖);Builder.account() 在校验失败路径上 self.accounts.setdefault(account, account) 的缓存内容及字符串驻留身份(错误发生后指令仍被产出,已由 beancount/parser/options_test.py:98-107 间接覆盖)。

10. 演变史

日期 提交 / 记录 变化
2014-07-05 c291509f5eabd40b is_valid()account_types 移入本模块,walk()parser.documents 上移到 core(当时路径为 src/python/beancount/core/account.py
2014-12-08 76b91a79 walk()dirsfiles 排序以稳定输出
2015-04-19 7da9e751 parent_matcher 改用词边界正则
2015-10-01 eef679f2 抽出可复用的账户正则常量
2016-04-09 a9f9ac89 首次实现 Custom 指令接受 ACCOUNT token:(value, dtype) 值对、TYPE = 'Account'、printer.py 的身份判断分支
2016-04-10 34c1eb9c TYPE 改成 '<AccountDummy>'、二元组换成 ValueType namedtuple,并在 CHANGES:2192-2207 中记录该功能
2016-06-18 84c28263 账户名允许 Unicode
2016-08-04 9723f2c0 后续组件允许单字符,首段仍需至少两字符(Fixed #138);CHANGES:1986 原文写作 "two characters",与示例 Assets:Investments:F 及 diff 不符,应视为记录措辞错误
2017-06-25 e16254ce 引入 AccountTransformer,服务实际参数 --no-colonsCHANGES:1316-1317 误记为单数 --no-colon
2017-08-08 CHANGES:1274-1276 组件允许纯数字
2018-01-07 CHANGES:1228-1231 允许单字符顶层账户名
2018-03-26 8b41d3a1 同步词法器与 Python 侧正则(Fixed #245,CHANGES:1063-1066
2018-04-12 / 2018-05-03 b7114f76e61d380c PR14:账户名支持 Unicode 字母与数字(CHANGES:859-860
2018-05-04 0cb1547e 修正 ACC_COMP_TYPE_REACC_COMP_NAME_RE 的定义被写反(Fixed #290,CHANGES:844
2019-01-29 18429bea parent_matcher\b 改为 ($|:)(Fixed #362,CHANGES:469-471
2019-02-26 07efa286 has_component 返回值改成真布尔(Fixed #376,CHANGES:449-451
2020-09-20 b6974795 测试拆出 TestAccountCoreOnly,供 ccore 复用
2023-09-18 1dc1639e45ccf0a1 改用 regex 模块;walk 中加 NFKC 归一化(Fixes #750)
2024-11-09 b534623207406fd5 改用 regex.fullmatch;新增 is_valid_root/is_valid_leaf 供选项校验(Fixes #754)
2024-12-23 0f879811 为配合 mypy,parent_matcher 显式返回 bool("enable mypy")
2025-01-19 / 2025-01-23 7c69601ac8803e3d walk 增加 followlinks 参数,四天后默认值由 False 改为 True(Fixed #933)
2026-08-22 a75c419c has_component 由未转义正则改为 component in account_name.split(sep)
2026-08-22 97472138 has_component 改回正则并加 re.escape,补 test_has_component_special_chars(基线 commit)

2017 年之前仓库布局带 src/python/ 前缀,commit 859f341e(2017-04-30)才把 src/python/beancount/... 移到 beancount/...;引用 c291509f5eabd40b76b91a797da9e751eef679f2a9f9ac8934c1eb9c84c282639723f2c0 时对应文件路径是 src/python/beancount/core/account.pye16254ce(2017-06-25)晚于该次迁移,其改动的文件路径已经是 beancount/core/account.py

11. 与其他模块的关系

12. 参考索引

beancount/core/account.py:1-6 模块 docstring;13-22 导入;25 Account;29 sep;32-38 组件正则与注释;40-41 ACCOUNT_RE;44-46 TYPE;49-58 is_valid_root;61-72 is_valid_leaf;75-84 is_valid;87-95 join;98-106 split;109-122 parent;125-134 leaf;137-150 sans_root;153-162 root;165-176 has_component;179-192 commonprefix;195-220 walk;223-233 parent_matcher;236-247 parents;250-273 AccountTransformer

beancount/core/account_test.py:13-25、27-35、37-45、47-51、53-57、59-62、64-71、73-79、81-86、88-104、107-118、121-131、133-160、163-178、181-186。

其它beancount/core/account_types.py:20,55,72-81,84-96beancount/core/data.py:21,779beancount/core/getters.py:173,241,260beancount/core/realization.py:162,185,191,312beancount/ops/balance.py:81beancount/ops/documents.py:52-58,117,129beancount/ops/pad.py:68beancount/parser/grammar.py:109-127,124,126,276-293beancount/parser/grammar.y:786-788beancount/parser/lexer.l:126,129-130,233-243,272-274beancount/parser/lexer_test.py:327-345beancount/parser/options.py:121-150,322-436,776-815beancount/parser/options_test.py:98-107beancount/parser/printer.py:412beancount/parser/printer_test.py:253-260beancount/scripts/directories.py:32,41,53-69beancount/scripts/doctor.py:191-209beancount/web/web.py:1177pyproject.toml:33CHANGES:449-451,469-471,844,859-860,1063-1066,1228-1231,1274-1276,1316-1317,1986-1988,2192-2207

commitc291509f5eabd40b(2014-07-05)、76b91a79(2014-12-08)、7da9e751(2015-04-19)、eef679f2(2015-10-01)、a9f9ac89(2016-04-09)、34c1eb9c(2016-04-10)、84c28263(2016-06-18)、9723f2c0(2016-08-04)、e16254ce(2017-06-25)、8b41d3a1(2018-03-26)、b7114f76(2018-04-12)、e61d380c(2018-05-03)、0cb1547e(2018-05-04)、18429bea(2019-01-29)、07efa286(2019-02-26)、b6974795(2020-09-20)、1dc1639e45ccf0a1(2023-09-18)、b534623207406fd5(2024-11-09)、0f879811(2024-12-23)、7c69601a(2025-01-19)、c8803e3d(2025-01-23)、a75c419c97472138(2026-08-22)。