page contents

日志配置不生效?这七个python陷阱让代码静默失效,排查全靠猜

这个问题我太熟了。卡卡第一次用 logging.config 的时候,也是这个状态——查了半天代码,代码一点错都没有;问题全在配置文件里,而配置文件出错时 它根本不告诉你。

attachments-2026-10-Rw1hc9SE6ac6f38de9057.png之前卡卡讲过 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,换个库立刻就报错了——所以自己写配置读取时,前面那两句检查值得照抄。

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、第六层坑:同一条日志打了两遍

反过来,还有一种情况是你 看到得太多:

一行日志刷了两遍。原因在 propagate 这个参数。日志会沿着名字逐级往上冒泡——app 打一遍,然后冒泡给 root,root 又打一遍:

[logger_app]
level=DEBUG
handlers=console
qualname=app
propagate=1     ← 冒泡给 root,root 也有 console,于是打两遍

把它设成 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 被关掉也不报错。它们要等到你“什么都看不到”的时候才暴露,而那时候你的注意力还在代码上。

所以用配置文件时,卡卡现在会做三件事:

1. 读文件后立刻检查 read() 的返回值,空列表就直接亮红灯,别让它拖到取值时才炸。

2. 给配置加一份最小化的体检:文件读到了没、关键节和选项在不在、level 是不是同一个值。几十行代码,能省掉一整个下午。

3. 改配置前先备份,因为 write() 回写会把文件里的注释全部吃掉。

更多相关技术内容咨询欢迎前往并持续关注好学星城论坛了解详情。

想高效系统的学习Python编程语言,推荐大家关注一个微信公众号:Python编程学习圈。每天分享行业资讯、技术干货供大家阅读,关注即可免费领取整套Python入门到进阶的学习资料以及教程,感兴趣的小伙伴赶紧行动起来吧。

attachments-2022-05-rLS4AIF8628ee5f3b7e12.jpg

 

你可能感兴趣的文章

相关问题

0 条评论

请先 登录 后评论
Pack
Pack

2480 篇文章

作家榜 »

  1. Pack 2480 文章
  2. 轩辕小不懂 2403 文章
  3. 小柒 2228 文章
  4. Nen 576 文章
  5. 王昭君 216 文章
  6. 文双 71 文章
  7. 小威 64 文章
  8. Cara 36 文章