找回密码
立即注册
搜索
发回帖 发新帖

4883

积分

0

好友

623

主题
发表于 1 小时前 | 查看: 7| 回复: 0

之前卡卡讲过 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 说“我不要”。 屏幕上安安静静,报错也没有,你只能来回读代码。

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

配置检查清单:文件已读、关键节存在、日志级别一致

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

  1. 读文件后立刻检查 read() 的返回值,空列表就直接亮红灯,别让它拖到取值时才炸。
  2. 给配置加一份最小化的体检:文件读到了没、关键节和选项在不在、level 是不是同一个值。几十行代码,能省掉一整个下午。
  3. 改配置前先备份,因为 write() 回写会把文件里的注释全部吃掉。

最后留个问题给你:你有没有遇到过那种“代码明明没错,但就是什么都没发生”的情况?欢迎来 云栈社区 技术论坛聊聊,卡卡看看是不是同一个坑。




上一篇:Gopeed开源下载器:多协议全平台,还能接入AI管任务
下一篇:给 AI 装了 128GB 记忆体还是记不住?因为给“日记本”不如给“字典”
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-6 21:16 , Processed in 0.088461 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表