之前卡卡讲过 configparser,说它是 python 自带、却很多人没发现的配置库《这么实用的配置库竟然是python自带,还好发现了它!》。文章发出去之后,后台收到一条留言,问得特别具体:
我用 .ini 配了日志,logger 和 handler 的 level 都写的 DEBUG,代码里也调了 log.info,但屏幕上就是什么都没有。这是为什么?
这个问题我太熟了。卡卡第一次用 logging.config 的时候,也是这个状态——查了半天代码,代码一点错都没有;问题全在配置文件里,而配置文件出错时 它根本不告诉你。
上一次讲的是“怎么读配置文件”,这篇换个方向:配置文件能读进来,不代表配置生效了。这里头有一串静默失败,一个比一个隐蔽。
1、第一层坑:read 失败了,它不吭声
先解决最基础的一件事。你有没有想过,配置文件压根没读进来的时候,程序是什么反应?
import configparser
config = configparser.ConfigParser()
config.read('config/not_there.ini') # 路径写错了
print(config.sections()) # []
不报错,也不抛异常,返回一个空列表就完事了。 它会一直到你去取值的那一刻,才把错误补上:
config.get('Server', 'Host')
# configparser.NoSectionError: No section: 'Server'
也就是说,路径拼错这件事,程序自己检查不出来,等你上线之后才炸——而且炸的地方离真正的原因十万八千里。
有个更省事的判断法,read 的返回值就是 成功读进来的文件名列表:
read_ok = config.read(['a.ini', 'b.ini'])
print(read_ok) # ['a.ini'] ← b.ini 根本没读到
if not read_ok: # 一个都没读到,直接亮红灯
raise SystemExit('配置文件没读到,检查路径')
后来我用 logging 的时候发现,标准库自己早就知道这个坑:logging.config.fileConfig 在读取之前会先自己查一遍:
# logging/config.py 里的真实代码
if not os.path.exists(fname):
raise FileNotFoundError(f"{fname} doesn't exist")
elif not os.path.getsize(fname):
raise RuntimeError(f'{fname} is an empty file')
不存在 -> FileNotFoundError,空文件 -> RuntimeError。 同样是用 .ini,换个库立刻就报错了。所以自己写配置读取时,你确实会在 read 失败了,它不吭声 这个点上吃亏,想避坑的话,前面那两句检查值得照抄。
2、第二层坑:DEFAULT 节会“传染”到每一个节
这一条是我踩过最莫名其妙的一个。
.ini 里有个特殊的节叫 [DEFAULT],它的作用是 给所有节提供默认值。听起来很贴心,但它的实际行为比你想的霸道得多:
[DEFAULT]
Level=low
[Server]
Host=1.1.1.1
[Params]
Count=3
读出来看看:
config.sections()
# ['Server', 'Params'] ← DEFAULT 自己不在列表里
config.items('Server')
# [('level', 'low'), ('host', '1.1.1.1')] ← 凭空多了个 level
config.items('Params')
# [('level', 'low'), ('count', '3')] ← 这里也多了
sections() 里看不到 DEFAULT,你以为它不存在;可只要你去 遍历任意一个节,DEFAULT 里的键就会自动混进来。如果你习惯用 dict(config.items(section)) 把节转成字典,那么每一个节都会凭空多出几个键。
它适合放“所有节都通用”的东西(比如统一的路径前缀),但如果你只是想写个注释一样的存在,那就千万别用它。
3、第三层坑:大小写被悄悄吃掉了
这个坑的形态很特别——写的时候不报错,读的时候两种写法都对,但你遍历出来的键名变了:
[Server]
Host=1.1.1.1
config['Server']['Host'] # '1.1.1.1' 能取到
config.get('Server', 'Host')
list(config['Server'].keys()) # ['host'] ← 存进去的是小写
因为 configparser 内部会把 选项名统一转成小写,但读取时对大小写不敏感。于是 Host 和 host 都能读到值,可你遍历出来的键永远是小写的 host。
为什么这算坑?因为你拿着遍历出来的键,去别的字典里查(比如 settings['Host']),就对不上了。
那如果同一个节里写 Host=a 和 HOST=b 两行呢?
# configparser.DuplicateOptionError:
# While reading from 'case.ini' [line 3]: option 'host' in section 'Server' already exists
直接报错。 因为它俩转成小写之后是同一个键。报错是好事,至少你知道自己写重了;但报错信息里的行号是指向第二行,别被它带偏。
4、第四层坑:值里带个百分号就炸
这条最反直觉,也最容易在真实数据上中招。先看这个文件:
[App]
path=100%done
config.get('App', 'path')
# configparser.InterpolationSyntaxError:
# '%' must be followed by '%' or '(', found: '%'
读一个普通的字符串都能报错?原因在于 configparser 默认开启了插值功能——%(key)s 是它的变量引用语法,会在读取时自动替换:
[DEFAULT]
root=/opt/app
[Path]
log=%(root)s/logs
config.get('Path', 'log') # '/opt/app/logs'
这个插值功能默认就是开的。 好处是配置能复用,坏处是任何值里出现孤零零的 %(比如“进度 100%”、“50%off”),都会被当成语法错误。
解法有两个,看你想要哪个:
# 方案一:要插值功能,那就把 % 写成 %%
config.get('App', 'path') # 原文件写 path=100%%done
# 方案二:不要插值,整个关掉
config = configparser.ConfigParser(interpolation=None)
config.get('App', 'path') # '100%done' 原样返回
我后来一律用方案二——配置里那点复用的便利,抵不上一个百分号带来的排查成本。
5、第五层坑:日志配了却不打印,可能卡在两道关卡
现在回到开头那条留言,把 logging 接上来。这就是我那个下午真正卡住的地方。
logging 的过滤 有两层,而且两层都要过:logger 自己的 level 过一遍,handler 的 level 再过一遍。
[logger_root]
level=INFO
handlers=console
[handler_console]
class=StreamHandler
level=WARNING
formatter=simple
args=(sys.stdout,)
上面这份配置,logger 是 INFO、handler 是 WARNING。于是:
log = logging.getLogger('app')
log.isEnabledFor(logging.INFO) # True ← logger 这一关过了
log.info('这条日志你看不到') # ← 但 handler 只放行 WARNING 以上
log.warning('这条才能出来') # 能看到
logger 说“放行”,handler 说“我不要”。 屏幕上安安静静,报错也没有,你只能来回读代码。

所以记住一句话:日志没出来,先别改代码,去看配置文件里 level 出现了几次。至少两个地方要同时对齐。
6、第六层坑:同一条日志打了两遍
反过来,还有一种情况是你 看到得太多:
INFO | app | 我只想出现一次
INFO | app | 我只想出现一次
一行日志刷了两遍。原因在 propagate 这个参数。日志会沿着名字逐级往上冒泡——app 打一遍,然后冒泡给 root,root 又打一遍:
[logger_app]
level=DEBUG
handlers=console
qualname=app
propagate=1
把它设成 propagate=0,日志就停在 app 这一层,不再往上传:
[logger_app]
propagate=0
这条没有报错、没有警告,只有你盯着两行一模一样的日志发呆。
7、第七层坑:default 参数把已有日志静默关掉
最后这条是本文里 后果最严重 的一个,因为它不报错、不打日志、也不提示。
fileConfig 有个参数叫 disable_existing_loggers,默认值是 True:
# 先建一个 logger
early = logging.getLogger('early.bird')
# 再读配置(没传第二个参数)
logging.config.fileConfig('log.ini')
print(early.disabled) # True ← 被关掉了
在读取配置之前创建的 logger,会被 静默关闭。你之后所有往 early.bird 打的日志,全部消失,一个字都不给你。
而且这个坑在“先 import、后配置”的正常项目结构里几乎必然发生——因为模块加载时 logger 就建好了,配置通常在主程序里才读。
解法就是显式传 False:
logging.config.fileConfig('log.ini', disable_existing_loggers=False)
顺带说一句,Python 3.9 之后官方更推荐用 dictConfig 配 logging,配置写成字典或 json,不用再跟 .ini 的 % 转义打架。但 .ini 那份可读性确实好,很多老项目也还在用,认识它上面的坑还是有必要的。
总结
把这七个坑摆在一起,会发现它们有个共同点:全都不报错。空文件不报错、DEFAULT 传染不报错、大小写被吃掉不报错、logger 被关掉也不报错。它们要等到你“什么都看不到”的时候才暴露,而那时候你的注意力还在代码上。

所以用配置文件时,卡卡现在会做三件事:
- 读文件后立刻检查
read() 的返回值,空列表就直接亮红灯,别让它拖到取值时才炸。
- 给配置加一份最小化的体检:文件读到了没、关键节和选项在不在、level 是不是同一个值。几十行代码,能省掉一整个下午。
- 改配置前先备份,因为
write() 回写会把文件里的注释全部吃掉。
最后留个问题给你:你有没有遇到过那种“代码明明没错,但就是什么都没发生”的情况?欢迎来 云栈社区 技术论坛聊聊,卡卡看看是不是同一个坑。