Python 3.11+:用 tomllib 读取配置并显式校验类型

141 次浏览4 条回复

Python 3.11 起可以用标准库 tomllib 读取 TOML。解析成功只代表语法正确,配置的必填项、类型和取值范围仍应在入口处显式校验。

环境:Python 3.11+;Linux、macOS 或 Windows;无第三方依赖。

创建 app.toml:

[app]
port = 8080
debug = false

创建 config.py:

from dataclasses import dataclass
from pathlib import Path
import tomllib


@dataclass(frozen=True)
class AppConfig:
    port: int
    debug: bool


def load_config(path: Path = Path("app.toml")) -> AppConfig:
    with path.open("rb") as file:
        raw = tomllib.load(file)

    app = raw.get("app")
    if not isinstance(app, dict):
        raise ValueError("missing [app] table")

    port = app.get("port")
    debug = app.get("debug", False)

    # bool 是 int 的子类,需要单独排除。
    if isinstance(port, bool) or not isinstance(port, int):
        raise ValueError("app.port must be an integer")
    if not 1 <= port <= 65535:
        raise ValueError("app.port must be between 1 and 65535")
    if not isinstance(debug, bool):
        raise ValueError("app.debug must be a boolean")

    return AppConfig(port=port, debug=debug)


if __name__ == "__main__":
    print(load_config())

运行:

python config.py

预期输出:

AppConfig(port=8080, debug=False)

这里使用二进制模式打开文件,因为 tomllib.load() 接收二进制文件对象。另一个容易忽略的细节是 isinstance(True, int) 为真;只检查 int 会错误地接受 port = true。把原始字典转换为不可变的数据类后,业务代码不再需要反复处理缺失键和动态类型。

还可以补一层未知键校验,主要用于尽早发现配置项拼写错误。当前实现里若写成 debgu = true,debug 会采用默认值 False,错误可能到运行时才被注意到。

环境:沿用正文的 Python 3.11+。在确认 app 是字典后加入:

allowed_keys = {"port", "debug"}
unknown_keys = set(app) - allowed_keys
if unknown_keys:
    names = ", ".join(sorted(unknown_keys))
    raise ValueError(f"unknown keys in [app]: {names}")

这样 debgu = true 会稳定报出 unknown keys in [app]: debgu。如果配置需要向前兼容,也可以只在 CI 中启用严格校验,生产运行时保留宽松读取。

建议把正文提到的 bool/int 边界直接固化成回归测试,避免后续重构时把判断简化成单独的 isinstance(port, int)。

环境:Python 3.11+;config.py 与正文一致;无第三方依赖。新建 test_config.py:

from pathlib import Path
from tempfile import TemporaryDirectory
import unittest

from config import load_config


class LoadConfigTests(unittest.TestCase):
    def load(self, text: str):
        with TemporaryDirectory() as directory:
            path = Path(directory) / "app.toml"
            path.write_text(text, encoding="utf-8")
            return load_config(path)

    def test_loads_valid_config(self):
        config = self.load("[app]\nport = 8080\ndebug = true\n")
        self.assertEqual(config.port, 8080)
        self.assertTrue(config.debug)

    def test_rejects_boolean_port(self):
        with self.assertRaisesRegex(ValueError, "must be an integer"):
            self.load("[app]\nport = true\n")

    def test_rejects_out_of_range_port(self):
        with self.assertRaisesRegex(ValueError, "between 1 and 65535"):
            self.load("[app]\nport = 65536\n")


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

运行 python -m unittest -v。这样既验证正常路径,也锁定了 TOML 布尔值不能被当作整数端口接受的行为。

校验字段之前,建议给读取阶段补一层带路径的错误上下文。服务同时加载多份配置时,仅看到 TOML 的行列号往往还不足以定位是哪一个文件;同时保留异常链,日志仍能看到原始原因。

环境:沿用正文的 Python 3.11+。可把读取部分改为:

class ConfigError(ValueError):
    pass


def load_config(path: Path = Path("app.toml")) -> AppConfig:
    try:
        with path.open("rb") as file:
            raw = tomllib.load(file)
    except FileNotFoundError as exc:
        raise ConfigError(f"config file not found: {path}") from exc
    except tomllib.TOMLDecodeError as exc:
        raise ConfigError(f"invalid TOML in {path}: {exc}") from exc

    # 后续字段校验保持不变

例如文件缺失和 TOML 语法错误会得到不同信息,调用方又可以统一捕获 ConfigError。这里不建议捕获整个 OSError,权限不足、目录误传等系统错误通常值得保留其原始类型。

还需明确默认配置路径的基准。Path("app.toml") 相对的是进程当前工作目录;同一程序从项目目录、systemd 或容器入口启动时,可能读取不同文件或直接报缺失。若配置随代码部署,可以把默认路径锚定到模块目录,同时仍允许调用方显式覆盖。

环境:Python 3.11+;目录中包含 config.py 与 app.toml。

from pathlib import Path

DEFAULT_CONFIG = Path(__file__).resolve().with_name("app.toml")


def load_config(path: str | Path = DEFAULT_CONFIG) -> AppConfig:
    path = Path(path)
    with path.open("rb") as file:
        raw = tomllib.load(file)

    # 后续校验保持不变

如果配置由运维挂载,更稳妥的接口是取消默认值,要求程序入口从命令行参数或环境变量解析出绝对路径,再传给 load_config()。关键是不要让库函数悄悄依赖启动目录。