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-6。Account = str(beancount/core/account.py:25)只是类型别名,没有包装类,"账户"的全部结构信息只剩字符串本身的形状:用冒号分层的组件序列
Assets:US:BofA:Checking。本模块负责三件事——定义这个形状的语法(正则)、在这个形状上做切分与拼接的纯函数、把文件系统目录树映射回账户名。
| 名称 | 位置 | 作用 |
|---|---|---|
Account = str |
beancount/core/account.py:25 |
类型别名,账户就是字符串 |
sep = ":" |
beancount/core/account.py:29 |
分层分隔符,被
account_types.py、grammar.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 |
冒号与替代分隔符的双向转换 |
# 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-21
用 Assets、Invalid、Other
三例锁定这条。
正则用的是 Unicode 属性类而非 [A-Z],非 ASCII
大写字母同样能出现在首字符位置;beancount/core/account_test.py:24-25
用大小写混合的反例(Assets:US:RBS:checking、Assets: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() 调用方式。
regex
而不是 re标准库 re 不支持 \p{...} 属性类。2023-09-18
的 commit 1dc1639e 把 is_valid 从
re.match 换成 regex.match,删掉此前的变通方案
beancount.utils.regexp_utils.re_replace_unicode。模块因此同时
import
两个正则库(beancount/core/account.py:14,22):is_valid_root/is_valid_leaf/is_valid
用 regex,而
has_component(beancount/core/account.py:176)与
parent_matcher(beancount/core/account.py:232)用标准库
re——这两处匹配的是字面量组件,不需要属性类。
2024-11-09 的 commit b5346232 把
regex.match("{}$".format(ACCOUNT_RE), string) 改成
regex.fullmatch(ACCOUNT_RE, string):$ 在
Python 正则里匹配"字符串末尾或末尾换行之前",fullmatch
不允许尾随换行,改完之后 is_valid("Assets:US\n") 为
False。
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,146:name_assets
等五个根名选项走 root 校验,account_previous_earnings
等七个叶子名选项走 leaf 校验。
is_valid_leaf 内部用字面量
string.split(":"),未走模块级的
sep(beancount/core/account.py:71),详见 8
节。
账户语法在仓库里有三份实现,彼此不完全等价。
第一份是 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-ONLY(beancount/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_root
为 True、is_root_account 为
False;"Assets\n"
反过来,is_valid_root 为
False、is_root_account 为
True。同文件
is_account_type(beancount/core/account_types.py:72-81)把
account_type 未经 re.escape()
直接插进表达式,风险见 8 节。
| 函数 | 位置 | 实现 | 空串输入 |
|---|---|---|---|
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("") →
None;parent("Expenses") →
"" |
leaf(name) |
beancount/core/account.py:125-134 |
取最后一段 | leaf("") → None |
sans_root(name) |
beancount/core/account.py:137-150 |
去掉第一段 | sans_root("") →
None;sans_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 |
返回判定谓词 | 见下 |
空串处理不统一。parent、leaf
有直接测试锁定空串行为:account.parent("") 与
account.leaf("") 均为
None(beancount/core/account_test.py:47-57)。sans_root
也在源码里判空返回
None(beancount/core/account.py:150),但
beancount/core/account_test.py:59-62
只断言了三个非空输入,sans_root("") 的 None
结果无测试覆盖,只能从实现推出。split、join、root
不判空,直接返回空串或 [""]。返回类型注解相应写成
Account | None(beancount/core/account.py:109,125,137),而
root 是
Account(beancount/core/account.py:153)。root
不做参数校验:负数按 Python 切片语义生效,root(-1, "A:B:C")
得到 "A:B"。
parent、leaf、sans_root
三者都有
assert isinstance(account_name, str)(beancount/core/account.py:117,133,148),传
None 抛 AssertionError;split
没有断言,传 None 抛 AttributeError。
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 使用。
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-Old 里 Checking
后面的 - 构成词边界,于是误判为子账户。CHANGES
记录该 bug "was causing a rare (but important) failure in how Pad
balances were
calculated"。beancount/core/account_test.py:108-113
同时锁定 CheckingOld 与 Checking-Old
两种否定情形。
调用点是 beancount/ops/pad.py:68 与
beancount/ops/balance.py:81,判断账户是否落在被断言的子树内。
2024-12-23 的 commit 0f879811("enable mypy")把
parent_matcher 从 return re.compile(...).match
改成 lambda s: bool(pattern.match(s)),让返回类型显式是
bool 而非 Match | None,行为不变。
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-79
用 Credit、Card
两个否定例锁定它不做子串匹配。
这一行在基线之前的两个提交里被改了两次,两个提交都在 2026-08-22 同一天:
a75c419c "Fix literal account component
matching":把正则整体换成
component in account_name.split(sep)。此前的正则没有
re.escape,component 里带
(、+、.
等元字符会当作正则片段解释——Foo)( 直接抛
re.error(unbalanced parenthesis),a.b 会把
. 当通配符。97472138(基线本身)"fix: echapper les caracteres
speciaux dans has_component":改回正则形式,但把 component
包进 re.escape,并新增
beancount/core/account_test.py:81-86 (test_has_component_special_chars)
锁定三个特殊字符输入返回 False
而不崩溃。测试注释保留了法语原文:"Avant le fix : crash (unbalanced
parenthesis). Apres : False."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_component 在 account.
前缀下没有调用点,它被 beancount/core/data.py:21 以
from beancount.core.account import has_component 引入,供
data.has_entry_account_component(beancount/core/data.py:779)判断一条
entry 的任一 posting 是否含某组件。
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 还做兼容折叠:全角
Assets→Assets,①→1,不同目录名可能折叠出同一账户名。
followlinks 参数默认 True,来自 2025-01-23
的 commit c8803e3d(Fixed #933);同月的
7c69601a 先把它作为可选参数引入、默认
False,四天后改成默认跟随。walk 把参数原样转给
os.walk,自己不维护已访问目录集合,os.walk
在此模式下也不做环检测,指向祖先目录的符号链接会使遍历无法自行终止。
relroot 用 root[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
节表格。
AccountTransformer
与 TYPEclass 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-265。rsep 为
None 时 render/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-colons。beancount/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 的
a9f9ac89(TYPE 当时取值
'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:412
用
dtype is account.TYPE,beancount/core/realization.py:312
用
custom_value.dtype == account.TYPE。字符串驻留、跨编译单元的字面量是否共享对象属于
CPython 实现细节,不是语言保证;解析器生成的 TYPE
对象身份及 printer.py 该分支已由
beancount/parser/printer_test.py:253-260 的 Custom
往返测试间接覆盖:若重新解析出的 dtype 不是同一个
TYPE 对象,printer.py
会把账户值当字符串加引号,往返比较即失败。
| 决策 | 理由 | 证据 |
|---|---|---|
| 账户是裸字符串而非对象 | 不携带 posting 列表,关联关系交给 realization 树 | beancount/core/account.py:1-6,25 |
| 首段与后续段用两条不同正则 | 首段是账户类型必须是字母;后续段允许纯数字(年份、账号) | beancount/core/account.py:37-38;CHANGES:1063-1066,1274-1276 |
用 regex + Unicode 属性类 |
标准库 re 不支持 \p{Lu};账户名需支持非
ASCII 字母 |
beancount/core/account.py:22;commit
1dc1639e、b7114f76 |
ACCOUNT_RE 要求至少两段 |
单个大写词在词法层是 flag/CAPITAL token,两段以上才无歧义 | beancount/core/account.py:41;beancount/parser/lexer.l:233-243;CHANGES:1228-1231 |
正则定义处放同步锚点 {5672c7270e1e} |
语法层要用配置根名替换首项,两处必须一起改 | beancount/core/account.py:40;beancount/parser/grammar.py:124 |
校验函数先查 isinstance(str) |
非字符串返回 False
而不抛异常,可直接用于选项值校验 |
beancount/core/account.py:58,70,84;beancount/parser/options.py:131,146 |
用 fullmatch 而非 match +
$ |
$ 允许尾随换行,fullmatch 不允许 |
commit b5346232 |
parent_matcher 用 ($|:) 而非
\b |
连字符构成词边界,\b 会把 Checking-Old
误判为 Checking 的子账户 |
commit 18429bea;CHANGES:469-471 |
has_component 对组件做 re.escape |
组件字符串可能来自用户输入,含元字符时不应崩溃 | commit
97472138;beancount/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-220;beancount/core/account_test.py:125-127,144-149 |
TYPE 用哨兵字符串而非新类型 |
让账户名在 Custom 指令里保持 str,改类型代价太大 |
beancount/core/account.py:44-46;CHANGES:2202-2205 |
| 现象 | 后果 | 证据 |
|---|---|---|
ACC_COMP_TYPE_RE 末位无限制 |
Assets:A-、A-:B 通过
is_valid |
beancount/core/account.py:37-38 |
is_valid_root("A") 为
True,account_types.is_root_account("A") 为
False |
两份根账户规则不等价:后者要求至少两字符、纯 ASCII、且用
$ 而非 fullmatch |
beancount/core/account.py:58;beancount/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,150;beancount/core/account_test.py:51,57,59-62 |
parent("Expenses") 返回 "" 而非
None |
顶层账户的父是空串;parents() 靠
while current_account 的真值判断在此停住 |
beancount/core/account.py:122,245;beancount/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_root 用
assert 做类型检查 |
python -O 下断言被剥离,None 输入改为抛
AttributeError 或返回 None |
beancount/core/account.py:117,133,148 |
has_component 的组件可含冒号并跨段匹配 |
has_component("Liabilities:US:Credit-Card", "US:Credit-Card")
为 True;a75c419c 的 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/US →
Assets:US);ops/documents.py 调用前先
path.normpath
故不会触发;scripts/directories.py
的两个函数本身不规范化路径,仅 doctor CLI 用
click.Path(resolve_path=True)
约束了输入,直接调用这两个函数仍可能触发 |
beancount/core/account.py:212;beancount/ops/documents.py:52-58;beancount/scripts/directories.py:41,53-69;beancount/scripts/doctor.py:191-209 |
walk 用 NFKC 而非仅 NFC |
不只合成分解字符,也做兼容折叠:全角
A→A、①→1,目录名到账户名不是一一映射 |
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 |
TYPE 的 is 比较依赖对象来源一致 |
C 层与 Python 层都从模块属性取同一对象,is
比较成立;细节及测试覆盖见 6 节 |
beancount/core/account.py:46;beancount/parser/grammar.y:786-788;beancount/parser/printer.py:412 |
| 无生产调用点的函数 | leaf、root、commonprefix、parents、has_component(以
account. 前缀调用的形式)、AccountTransformer
在非测试代码中均无调用;has_component 经
beancount/core/data.py:21 的直接引入被使用 |
全仓库 grep |
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("") 为
None;sans_root("Assets") 为
"" |
test_root |
64-71 | n=0 到 n=5,超出组件数返回全名 |
test_has_component |
73-79 | 整段匹配;Credit、Card 不匹配
Credit-Card |
test_has_component_special_chars |
81-86 | Foo)(、a.b、a+b 返回
False 而非崩溃 |
test_commonprefix |
88-104 | 逐组件求前缀;无共同前缀返回 "" |
TestAccountCoreOnly.test_parent_matcher |
108-113 | 自身与子账户为真;CheckingOld、Checking-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:99Ranch、Assets:99Test
覆盖,account.is_valid 没有对应的直接测试。
文件末尾有一段条件删除(beancount/core/account_test.py:181-186):以
__main__ 方式运行时,若 account 模块没有
__copyright__ 属性,就删掉
TestAccountTransformer、TestWalk、TestAccountCoreOnly
三个类,只留 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("");walk
的 followlinks、尾随分隔符与 NFKC
兼容折叠;AccountTransformer 的 rsep
冲突;手工构造的、与 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 间接覆盖)。
| 日期 | 提交 / 记录 | 变化 |
|---|---|---|
| 2014-07-05 | c291509f、5eabd40b |
is_valid() 从 account_types
移入本模块,walk() 从 parser.documents 上移到
core(当时路径为
src/python/beancount/core/account.py) |
| 2014-12-08 | 76b91a79 |
walk() 对 dirs、files
排序以稳定输出 |
| 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-colons(CHANGES: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 | b7114f76、e61d380c |
PR14:账户名支持 Unicode
字母与数字(CHANGES:859-860) |
| 2018-05-04 | 0cb1547e |
修正 ACC_COMP_TYPE_RE 与 ACC_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 | 1dc1639e、45ccf0a1 |
改用 regex 模块;walk 中加 NFKC
归一化(Fixes #750) |
| 2024-11-09 | b5346232、07406fd5 |
改用 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 | 7c69601a、c8803e3d |
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/...;引用
c291509f、5eabd40b、76b91a79、7da9e751、eef679f2、a9f9ac89、34c1eb9c、84c28263、9723f2c0
时对应文件路径是
src/python/beancount/core/account.py。e16254ce(2017-06-25)晚于该次迁移,其改动的文件路径已经是
beancount/core/account.py。
regex,这是它能被
account_types.py、data.py
等底层模块无环引入的前提。account_types.py:from beancount.core import account(beancount/core/account_types.py:20),用
account.split() 取首段实现
get_account_type(beancount/core/account_types.py:55),用
account.sep 拼 is_account_type
的正则(beancount/core/account_types.py:81),并另行维护一份
is_root_account
正则(beancount/core/account_types.py:96,见 3.4)。parser/grammar.py:valid_account_regexp(options)(beancount/parser/grammar.py:109-127)复用
sep 与
ACC_COMP_NAME_RE,把首项替换成配置的五个根名;Builder.account()(beancount/parser/grammar.py:276-293)用
.match() 调用它并缓存校验结果。parser/options.py:is_valid_root
校验 5 个根名选项,is_valid_leaf 校验 7
个叶子名选项(beancount/parser/options.py:131,146,322-436);account.join
用于拼装 Equity:Opening-Balances
等派生账户名(beancount/parser/options.py:776-815)。core/realization.py:account.split
沿组件下降建树,account.join(*path)
生成每个节点的全名(beancount/core/realization.py:162,185,191);account.TYPE
用于把 Custom
指令挂到对应账户(beancount/core/realization.py:312)。core/getters.py:account.split
收集全部组件(beancount/core/getters.py:173,241,260)。core/data.py:has_component
支撑
has_entry_account_component(beancount/core/data.py:21,779)。ops/pad.py、ops/balance.py:parent_matcher
判定账户是否落在子树内(beancount/ops/pad.py:68、beancount/ops/balance.py:81)。ops/documents.py、scripts/directories.py:walk
把文档目录树映射成账户名(beancount/ops/documents.py:129、beancount/scripts/directories.py:41)。parser/lexer.l:账户语法的另一份实现,按
UTF-8 字节工作,比 Python 侧宽松(见 3.4)。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-96;beancount/core/data.py:21,779;beancount/core/getters.py:173,241,260;beancount/core/realization.py:162,185,191,312;beancount/ops/balance.py:81;beancount/ops/documents.py:52-58,117,129;beancount/ops/pad.py:68;beancount/parser/grammar.py:109-127,124,126,276-293;beancount/parser/grammar.y:786-788;beancount/parser/lexer.l:126,129-130,233-243,272-274;beancount/parser/lexer_test.py:327-345;beancount/parser/options.py:121-150,322-436,776-815;beancount/parser/options_test.py:98-107;beancount/parser/printer.py:412;beancount/parser/printer_test.py:253-260;beancount/scripts/directories.py:32,41,53-69;beancount/scripts/doctor.py:191-209;beancount/web/web.py:1177;pyproject.toml:33;CHANGES:449-451,469-471,844,859-860,1063-1066,1228-1231,1274-1276,1316-1317,1986-1988,2192-2207。
commit:c291509f、5eabd40b(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)、1dc1639e、45ccf0a1(2023-09-18)、b5346232、07406fd5(2024-11-09)、0f879811(2024-12-23)、7c69601a(2025-01-19)、c8803e3d(2025-01-23)、a75c419c、97472138(2026-08-22)。