先说一个我遇到过不止一次的事故
某个 Python 写的桌面工具,用户配置存在 config.json 里,其中有一项是第三方服务的 API key。
用户为了排查问题,把整个配置目录打包发给了客服。key 跟着压缩包一起出去了。
这类事故的根因不是「用户不小心」,而是我们把不该明文落盘的东西,用最方便的方式落盘了 :json.dump 一行代码,问题当场解决,密文一行代码都还没写。
如果你也在写带配置文件的 Python 工具,下面三个缺口大概率同时存在:
- 明文 。
config.json、settings.ini打开就能看,改一行就能生效。 - 没有类型和校验 。
configparser读出来永远是字符串,"true"和True靠调用方自己猜;数值改成99999也照单全收,直到运行时才炸。 - 没有版本迁移。老用户机器上留着 v1 的字段,新代码只认 v2,于是「新装没问题、老用户升级就崩」。
python-dotenv、pydantic-settings 解决的是第 2 点的一部分,但对第 1、3 点基本不管;dynaconf 功能很全,代价是引入一套自己的运行时模型。
我想要的是另一种东西:配置结构用类和属性声明出来,读写在类型上收口,加密、校验、迁移都由这个结构自动完成。
这就是 conf_base 想做的事。下面是它解决同样问题时,代码长什么样。
三十秒版本的对比
| 你现在的做法 | 痛点 | 换成声明式之后 |
|---|---|---|
json.load + 字典 |
拼错 key 要到运行时才发现 | 拼错属性名当场 AttributeError |
configparser 读字符串 |
每次都要 int() / == 'true' |
类型由 default 决定,写错类型直接报错 |
| 明文文件 | 密钥、token 裸奔 | 一行 _cipher,落盘即密文 |
| 手改配置文件 | 越界值、非法枚举照单全收 | validator 在写入前拦截 |
| 新增字段 | 老用户的旧文件没人管 | _schema_version + _migrate 自动升级 |
从字典搬到类:五行
下面用到 AES-256-GCM,需要额外装加密依赖:
pip install "conf-base[aes]"。 只用明文格式 + 校验的话,pip install conf-base就够。
python
from conf_base import ConfigBase, Section, AesGcmCipher
class ServerConfig(ConfigBase):
_path = 'server.json.enc'
_cipher = AesGcmCipher(key_env='APP_SECRET')
server = Section('Server')
secret = server.field(default='', read_only=True)
port = server.field(default=8080, validator=lambda v: 1 <= v <= 65535)
workers = server.field(default=4, validator=lambda v: 1 <= v <= 64)
类定义完成的那一刻,server.json.enc 就被创建好了,缺失的字段自动补默认值。之后所有读写都走属性:
python
ServerConfig.port.value = 9090 # 校验通过 → 立刻落盘
ServerConfig.port.value # 9090
ServerConfig.port.value = 70000 # ConfigValueError:校验失败
没有 load()、没有 save()、没有「改了忘了写回」这类 bug------赋值即持久化。
加密:磁盘上只有一个信封
_cipher = AesGcmCipher(key_env='APP_SECRET') 之后,文件里不再是 JSON 明文,而是这样一个信封:
json
{
"v": 1,
"alg": "AES-256-GCM",
"kid": "v1",
"nonce": "...",
"data": "..."
}
明文里只有算法、密钥编号和 nonce,配置值在 data 里,没有密钥读不出来。 密钥可以从环境变量取(示例里的 APP_SECRET),也可以代码里注入;AES-256-GCM 是 AEAD,篡改一个字节解密就会失败,不会悄悄返回错误数据。
如果只是防手改、不要求保密,用 SignedCipher 更轻:文件仍是明文,但末尾会附一行 HMAC-SHA256 签名,被改过就拒绝加载。
校验:把「运行时才炸」提前到「写入时就炸」
python
port = Section('Server').field(
default=8080,
validator=lambda v: isinstance(v, int) and 1 <= v <= 65535,
)
validator 是任意 Callable[[Any], bool]。返回 False 抛 ConfigValueError,抛异常则会被包装成 ConfigValueError 并保留原始异常链。两种失败都会阻止写入,配置文件不会被污染。
类型这一层框架已经替你挡了:值的类型永远与 default 相同,True 不会被当成 1 存进去,"8080" 也不会被悄悄转换。
迁移:老用户的配置文件怎么活下来
python
def migrate_v1_to_v2(cfg):
server = dict(cfg['Server'])
server['workers'] = server.pop('threads')
return {**cfg, 'Server': server}
class AppConfig(ConfigBase):
_path = 'app.json'
_schema_version = 2
_migrate = {1: migrate_v1_to_v2}
server = Section('Server')
workers = server.field(default=4)
文件里的 _schema_version 是 1,代码要求 2,读文件时自动调用 1 对应的迁移函数,结果写回并把版本号更新为 2。版本号比代码还新(用户装过更新版又回退)则直接抛 SchemaError,不会拿新格式喂给旧代码。
迁移函数签名就是 dict -> dict,可以随便拆函数、写测试,不需要理解库内部。
还有两个细节
环境变量覆盖 :field(env_key='APP_PORT') 之后,环境变量优先级最高,而且被覆盖的项禁止回写------防止你在代码里改了半天,落盘时把环境变量里的值写进文件。
批量写入 :with AppConfig.server.batch(): 里的多次赋值只在退出时落盘一次,中途异常整批回滚到进入前的状态。桌面应用里「改 10 个设置点一次确定」这种场景,省掉 9 次磁盘 IO。
什么时候别用它
- 你只是读三个环境变量------
python-dotenv更合适; - 配置来自远程配置中心(Nacos、Apollo、etcd)------这个库只管本地文件;
- 配置需要高频写入(每秒级)------它按「人改配置」的频率设计;
- 配置结构复杂到需要嵌套模型校验------
pydantic-settings的表达力更强。
它解决的是「本地有一个配置文件,希望它类型安全、能加密、能被校验、能升级」这一类问题,仅此而已。
安装
bash
pip install conf-base
不需要任何强制依赖;要 YAML/TOML 或加密再按需装扩展:
bash
pip install "conf-base[yaml,toml,aes]"
仓库:GitHub | Gitee 三个可直接运行的示例在 examples/,中英双语文档在 README。
如果这个思路对你有用,点个 star 是最好的反馈;有具体场景想讨论,开 issue 就行。