🐍 第5章 建立命令行工具:下载 YouTube 影片
📄 一句话概括

key 返回什么就按什么排。要多级排序,让 key 返回一个元组:先比第一项,相等再比第二项。 try 有四个块:try、except、else、finally。 else 是没出错才走,finally 是不管出不出错都走。 调外部程序要用列表,不要拼字符串。而且「命令不存在」和「命令跑失败」是两回事,得分开处理。

📝 本章和前面几章的关系

这是第 5 章最后一篇,也是全书第一个「完整的命令行工具」收尾。 教材第 5 章最后几节是【数据排序】【使用 try except 捕捉例外状况】 还有【使用 FFmpeg 转换多媒体文件格式】和【自定义程序模块】。 四件事凑在一起,是因为一个真能干活的下载器四样都得有:按画质排、出错不崩、调 FFmpeg 合流、 把公用函数抽成模块。 本篇有个容易忽略的细节,在演示第四段: ffmpeg 没装的时候 Python 抛的是 FileNotFoundError,不是返回非零退出码。 这两种情况要分开处理。

一、一列按什么排:key 与多级排序

第 3.3 篇用过 sorted(items, key=lambda t: t[1])。这里把它讲透。 key 是一个函数,sorted 对每个元素调一次它,按返回值排序。你返回什么,它就按什么排。 演示第一段有四个文件,按分辨率降序排。 注意两个都是 1080 的(教程 A 和教程 C), 单按分辨率排时谁在前取决于原始顺序——这叫排序是稳定的。

稳定有什么用?意味着「先按名字排一遍,再按分辨率排一遍」,同分辨率的那些还能保持名字的顺序。 要多级排序,让 key 返回一个元组:key=lambda t: (-t[1], t[2])。 元组比较是先比第一项,相等再比第二项,以此类推。 降序怎么办?数字前面加负号最简单(-t[1])。负数比正数小,排出来就是大的在前。 字符串不能加负号,得用 reverse=True。

演示里加了第二级条件之后,教程 C 排到了教程 A 前面——因为它的文件更小。 这就是多级排序的意义:第一个条件打平时,用第二个决定。

二、try 的四个块各干什么

块 什么时候执行
try 总是先执行这里面
except try 里抛出对应异常时
else try 里没出错时
finally 不管出不出错,最后都执行

演示第二段:输入 '120',走 try 成功、else 执行、finally 执行; 输入 '12O'(字母 O),except 抓到 ValueError、finally 照样执行。 else 很容易被忘掉。它的用处是:把「可能出错的代码」和「出错之后才该走的代码」分开。 比如 try: 打开文件 / except: 报错 / else: 读文件内容。 读文件不该放在 try 里,否则读取时的错误也会被当成「打开失败」。

finally 是收尾用的。关文件、关数据库连接、删临时文件,放这里才不会漏。 哪怕 try 里 return 了,finally 也会执行。 Python 还有个 with 语句能自动关文件,比手写 finally 更省事。第 7 章会用到。 但 finally 适用的场景比 with 广得多,得会。

三、四种常见异常长什么样

代码 异常 意思
1 / 0 ZeroDivisionError 除以零
int('abc') ValueError 值不对,转不了
{'a':1}['b'] KeyError 字典没这个键
open('没有这个文件') FileNotFoundError 文件不存在
[1][9] IndexError 下标越界
'a' + 1 TypeError 类型不匹配

这六个名字背下来,看报错就不用猜了。它们都是 Exception 的子类。 所以 except Exception: 能一网打尽——但也别什么都抓。 抓太宽会把真正的 bug 一起吞掉。 比如你打错一个变量名,NameError 被吞了,程序继续往下跑,最后报一个完全不相干的错, 排查时间翻十倍。

原则是: 能预料到的错误精确抓,预料不到的就让它炸。 炸出来的报错信息比你自己写的「出错了」有用得多。

四、调用外部程序:列表不是字符串

subprocess.run(['ffmpeg', '-i', src, dst])——注意参数是列表,不是拼好的字符串。 为什么不能拼字符串?两个原因。 一是路径里有空格就散架了:'ffmpeg -i C:\我的 视频.mp4' 会被当成两个参数。 二是安全。拼字符串时,用户输入里如果有特殊字符,可能被当成命令执行。 列表形式让系统自己去处理引号和转义,两个问题一起解决。

演示第四段有个关键细节:调 python 成功了, 调 ffmpeg 抛了 FileNotFoundError——这台机器没装 ffmpeg。 报错形式不一样。 命令压根不存在时,Python 抛 FileNotFoundError; 命令存在但跑失败了,才体现在返回码上。 这两种情况必须分开处理。 只判返回码的话,没装 ffmpeg 的机器会直接崩在调用那一行,而且报的是一个跟 ffmpeg 无关的错。

标准写法是外面套一层 try: try: subprocess.run(...) except FileNotFoundError: print('没装 ffmpeg,请先安装')。 这样用户看到的是人话,不是堆栈。

五、转格式的那条命令

subprocess.run(['ffmpeg', '-i', src, '-vn', '-acodec', 'libmp3lame', '-q:a', '2', dst])。 逐段读:-i 输入文件,-vn 不要视频(video no),-acodec 音频编码器选 libmp3lame, -q:a 2 音质(数字越小越好),最后是输出文件。 -vn 这个参数是关键: 不写它,ffmpeg 会想办法把视频也塞进 mp3,然后失败。因为 mp3 这个容器根本装不了视频。

还有一个参数必须加:check=True。它的意思是「出错就抛异常」。 不加会怎样? ffmpeg 失败了只体现在返回码上,脚本以为成功了,继续往下走,最后交给你一个 0 字节的文件。 这一类 bug 最难查:程序不报错,文件也生成了,就是打不开。 所以调外部程序,check=True 应该是默认动作,除非你有理由不写。

六、把自己的函数拆成模块

程序写到几百行,就该拆了。 同目录下的 .py 文件,import 名字 就能用,不用装、不用改 sys.path。 演示第六段:main.py 里 import helper,拿到的 helper 就是同目录那个文件。 文件里定义的函数直接 helper.函数名() 调。 拆的原则很简单:「到处都要用的小函数」抽出去,「只在这一处用的」留在原地。

抽完之后主程序只剩流程:读参数 → 下视频 → 转格式 → 存盘。 读代码的人一眼能看懂它在干什么,不用陷进细节里。 这也解释了第 4.3 篇为什么要写 if __name__ == '__main__':拆出去的 helper.py 既想能单独跑测试, 又想被 main.py import 时不乱跑。 再往后一步就是打包成包(带 __init__.py 的文件夹)。 那是另一个话题,本书第 9 章建 Flask 项目时会用到目录结构,但不会深讲打包。

七、动手 Demo:排序、出错、调用外部程序

先看单级排序和多级排序的差别,注意那两个都是 1080 的文件顺序变了。 再把 try 的四个块跑一遍,看哪个块什么时候执行。 然后调两个外部程序:一个存在、一个不存在,看报错形式差在哪。 最后验证同目录的 .py 能不能直接 import。

import os, subprocess, sys, tempfile

print('① 一列按什么排')
files = [('教程A.mp4', 1080, 900), ('教程B.mp4', 720, 400),
         ('教程C.mp4', 1080, 100), ('教程D.mp4', 480, 200)]
print('  原顺序', [f[0] for f in files])
print('  按分辨率降序         ', [f[0] for f in sorted(files, key=lambda t: -t[1])])
print('  分辨率降序 + 大小升序 ', [f[0] for f in sorted(files, key=lambda t: (-t[1], t[2]))])

print('② try 的四个块各干什么')
def parse(s):
    try:
        n = int(s)
    except ValueError:
        print('    except 抓到 ValueError;finally 一定执行')
        return 0
    else:
        print('    try 成功;else 执行(没出错才走);finally 一定执行')
        return n
    finally:
        pass
print("  输入 '120' → 结果", parse('120'))
print("  输入 '12O' → 结果", parse('12O'))

print('③ 四种常见异常长什么样')
for tag, fn in (('1 / 0', lambda: 1 / 0),
                ("int('abc')", lambda: int('abc')),
                ("{'a':1}['b']", lambda: {'a': 1}['b']),
                ('open(没有这个文件)', lambda: open('没有这个文件'))):
    try:
        fn()
    except Exception as e:
        print('    %-18s → %s: %s' % (tag, type(e).__name__, str(e)[:36]))

print('④ 调用外部程序')
P = sys.executable
r = subprocess.run([P, '-c', 'import sys; print("子进程跑起来了, 参数", sys.argv[1:])',
                    'a', 'b'], capture_output=True, text=True)
print('  调 python -c:', r.stdout.strip())
r2 = subprocess.run([P, '-c', 'import sys; sys.exit(2)'])
print('  子进程退出码 %d(假失败)' % r2.returncode)
try:
    subprocess.run(['ffmpeg', '-version'], capture_output=True)
except FileNotFoundError:
    print('  调 ffmpeg     → FileNotFoundError(这台机器没装)')

print('⑤ 转格式的那条命令长什么样')
print('  [平台原样 · 本沙箱不执行]')
print('  | # 本书第 5-9 节的样子(平台原样,本沙箱不执行)')
print('  | subprocess.run([\'ffmpeg\', \'-i\', src, \'-vn\', \'-acodec\', \'libmp3lame\',')
print('  |                 \'-q:a\', \'2\', dst], check=True)')
print('  |')
print('  | # -i 输入  -vn 不要视频  -acodec 音频编码  -q:a 2 音质(越小越好)')
print('  check=True 的意思是「出错就抛异常」,不然失败了也是静悄悄的')

print('⑥ 把自己的函数拆成模块')
d = tempfile.mkdtemp()
open(os.path.join(d, 'helper.py'), 'w').write(
    'def human(n):\n'
    '    return "%.1f KB" % (n / 1024)\n'
    'def ext(name):\n'
    '    return name.rsplit(".", 1)[-1]\n')
open(os.path.join(d, 'main.py'), 'w').write(
    'import helper\n'
    'print("   ", helper.human(1536))\n'
    'print("   ", helper.ext("a.b.c.mp4"))\n')
r = subprocess.run([P, 'main.py'], capture_output=True, text=True, cwd=d)
print('  main.py 里 import helper:')
for ln in r.stdout.strip().splitlines():
    print('  ' + ln)
print('  同目录下的 .py 文件,import 名字就能用,不用装、不用改 sys.path。')

实跑输出:

--------------------------------------------------------------------
排序、出错、调用外部程序
--------------------------------------------------------------------

  ① 一列按什么排
  --------------------------------------------------------------------
    原顺序 ['教程A.mp4', '教程B.mp4', '教程C.mp4', '教程D.mp4']
    按分辨率降序          ['教程A.mp4', '教程C.mp4', '教程B.mp4', '教程D.mp4']
    分辨率降序 + 大小升序  ['教程C.mp4', '教程A.mp4', '教程B.mp4', '教程D.mp4']
  两个都是 1080 的,单按分辨率排时谁在前取决于原始顺序(排序是稳定的);
  加上第二级条件,文件小的就排到前面去了。
  key 返回什么就按什么排。要多级排序,让 key 返回一个元组:元组先比第一项,相等再比第二项。
  想降序就在数字前加负号。

  ② try 的四个块各干什么
  --------------------------------------------------------------------
    输入 '120' → 结果 120  try 成功;else 执行(没出错才走);finally 一定执行
    输入 '12O' → 结果 0    except 抓到 ValueError;finally 一定执行
  else 是「try 里没出错」才走,finally 是「不管出不出错」都走。
  关文件、关数据库连接这类收尾动作放 finally,才不会漏。

  ③ 四种常见异常长什么样
  --------------------------------------------------------------------
    1 / 0                  → ZeroDivisionError: division by zero
    int('abc')             → ValueError: invalid literal for int() with
    {'a':1}['b']           → KeyError: 'b'
    open('没有这个文件')         → FileNotFoundError: [Errno 2] No such file or dire
  记住这几个名字就够用:除以零是 ZeroDivisionError,转换失败是 ValueError,
  字典没这个键是 KeyError,文件没找到是 FileNotFoundError。
  它们都是 Exception 的子类,所以 except Exception 能一网打尽——但也别什么都抓,
  抓太宽会把真正的 bug 一起吞掉。

  ④ 调用外部程序
  --------------------------------------------------------------------
    调 python -c:子进程跑起来了, 参数 ['a', 'b']
    子进程退出码 2(假失败)
    调 ffmpeg     → FileNotFoundError(这台机器没装)
  subprocess.run 把参数列成列表,不拼字符串。拼字符串遇到带空格的路径就散架,而且有注入风险。
  列表形式系统自己处理引号。
  注意这次的报错形式:命令压根不存在时,Python 抛 FileNotFoundError;
  命令存在但跑失败了,才体现在返回码上。
  这两种情况要分开处理——只判返回码的话,没装 ffmpeg 的机器会直接崩在调用那一行。

  ⑤ 转格式的那条命令长什么样
  --------------------------------------------------------------------

  [平台原样 · 本沙箱不执行]
  | # 本书第 5-9 节的样子(平台原样,本沙箱不执行)
  | subprocess.run(['ffmpeg', '-i', src, '-vn', '-acodec', 'libmp3lame',
  |                 '-q:a', '2', dst], check=True)
  |
  | # -i 输入  -vn 不要视频  -acodec 音频编码  -q:a 2 音质(越小越好)
  check=True 的意思是「出错就抛异常」,不然失败了也是静悄悄的

  check=True 很关键。
  不加的话 ffmpeg 失败只体现在返回码上,脚本会以为成功了,继续往下走,最后交出一个 0 字节的文件。

  ⑥ 把自己的函数拆成模块
  --------------------------------------------------------------------
    main.py 里 import helper:
      1.5 KB
      a_b_c_.mp4
  同目录下的 .py 文件,import 名字就能用,不用装、不用改 sys.path。
  把「到处都要用的几个小函数」抽成模块,主程序就只剩流程,读起来清楚得多。

第一段两张排序结果不一样,原因在教程 A 和教程 C 都是 1080。 单按分辨率排时它们谁在前取决于原顺序(排序是稳定的);加上第二级条件,文件小的那个就排到前面了。 第二段那两句打印是关键:输入合法时走 else,不合法的走 except,而 finally 两条路都走。 else 容易被忘掉,它解决的是「把可能出错的代码和后续代码分开」。 第三段六个异常名字里,TypeError 和 IndexError 没在演示里跑,但都在正文那张表里列着。 记住类型名比背错误原文有用。 第四段是本篇最该记住的一处:调 ffmpeg 时抛的是 FileNotFoundError,不是返回码。 命令不存在和命令跑失败,是两种完全不同的情况,得分开处理。 第六段说明拆模块有多简单:同目录的 .py,import 名字就能用。 这也是第 4.3 篇那个 if name 判断真正派上用场的地方。

30 秒实验

  1. 把演示第一段的 -t[1] 改成 t[1],看排序结果反过来没有。
  2. 把演示第二段的 except ValueError 改成 except TypeError,再输 '12O', 看程序会发生什么(提示:异常没被接住,会往上抛)。
  3. 把演示第四段调 ffmpeg 的那一行改成传字符串而不是列表,看它能不能跑,再想想为什么不该这么写。

常见误区

  1. 「try 只用来接错误,else、finally 是多余的。 」else 把「可能出错的」和「出错后才该走的」分开, finally 保证收尾一定执行——哪怕 try 里 return 了。
  2. 「except Exception 最保险,全都抓。」它会把真正的 bug 一起吞掉。 你打错一个变量名,NameError 被吞,程序继续跑,最后报一个不相干的错。
  3. 「调外部程序看不出区别,反正失败就看返回码。 」命令不存在时抛的是 FileNotFoundError,根本不返回。 只判返回码的代码会在没装工具时崩在调用那一行。
自测三问(点开对答案) 1. 要让 `sorted` 先按分辨率降序、分辨率相同再按文件大小升序,key 该怎么写? —— `key=lambda t: (-t[1], t[2])`。返回元组,比较时先比第一项;第一项相等再比第二项。 降序靠在第一项前加负号,因为负数比正数小,排出来就是大的在前。 2. `try` / `except` / `else` / `finally` 四个块,哪个在「没出错」的时候执行? —— `try` 和 `else` 会在没出错时执行,`finally` 不管出不出错都执行。`except` 只在出错时执行。 `else` 的价值是把「可能出错的代码」和「出错之后才该走的代码」分开。 3. 为什么调外部程序要用列表 `['ffmpeg', '-i', src]` 而不是拼一个字符串? —— 两个原因。一是路径里有空格时字符串会被拆成多个参数,直接散架; 二是安全,用户输入里的特殊字符可能被当成命令执行。 列表形式让系统自己处理引号和转义,两个问题一起解决。

第 5 章结束。下一篇进第 6 章:网页到底是什么,怎么用程序去读它。

相关笔记