手里拿着一个 .ipynb 文件,想把它当成普通 Python 脚本来用——丢到服务器上跑定时任务、放进项目仓库做代码评审,或者就是习惯了在 IDE 里看代码,这个需求我遇到过太多次了。每次都有新人跑来问:直接把后缀改成 .py 行不行?答案是显而易见的,但我猜你在问之前已经悄悄试过一次了。今天这篇就专门讲 .ipynb 转 .py 这件事,从最无脑的图形界面导出一路到命令行批量转换、再到长期维护更友好的双向同步方案,把每一步的坑和体验都摊开讲。
不管你是在校学生处理课程实验,还是工程师要把同事发来的 notebook 收编进项目,这篇文章都能让你少走几圈弯路。我会先解释两种文件的本质区别,再给四套方案,最后附上高频踩坑记录。内容不追求大而全,只求每一句都是实操里用得上的。
1. 先搞清楚 .ipynb 和 .py 的本质区别,再谈转换
1.1 .ipynb 不是代码文件,而是一个 JSON 容器
很多人第一次拿到 .ipynb 会下意识双击打开,结果发现系统根本不知道拿什么程序关联它。这是因为 .ipynb 全称是 Jupyter Notebook 文件,它的本体是一个 JSON 文档,用来承载"代码"和"代码的产出物"以及"说明文字"的复合内容。
你可以用任何文本编辑器打开一个 .ipynb 看一眼,会看到类似下面这样的结构:
{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# 这是标题\n", "这里是说明文字" ] }, { "cell_type": "code", "execution_count": 3, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "hello world\n" ] } ], "source": [ "print('hello world')" ] } ], "metadata": { "language_info": { "name": "python" } }, "nbformat": 4, "nbformat_minor": 5 }这里有几个关键字段你得眼熟:
cells:所有内容单元,每个单元由cell_type区分是 markdown 还是 code。source:单元格里的源码文本。outputs:代码执行后产生的输出、图表、富文本结果,这部分是导成 .py 时会被丢弃的。metadata:内核信息、语言信息、单元格运行状态等杂项。nbformat:notebook 格式版本,目前主流是 4。
理解了这层结构,你就明白了:Notebook 从设计上就不只是"能运行的代码",它是一个把代码、运行结果、分析思路打包在一起的互动文档。而 .py 是纯文本的 Python 源码文件,解释器直接按语句顺序执行,没有任何"单元格"的概念。
1.2 直接把后缀改成 .py 会发生什么?
这是新手最常踩的坑,我一律建议先别看答案,自己亲手试一次,印象深刻。把demo.ipynb改名为demo.py,然后在命令行执行:
python demo.py你会看到一大片语法错误。因为 Python 解释器拿到的是一堆 JSON 字符串,里面全是引号、冒号、方括号、花括号,而 Python 语法要求这些符号有特定语义。最典型的是缩进报错和字符串引号不匹配,错误信息可能指向文件第一行,也可能指向第几百行,完全看命。
也就是说,.ipynb 是"用 JSON 打包的文档容器",.py 是"纯代码文本",两者不能靠改后缀互通。正确的思路是把 notebook 里的source字段提取出来,重新拼成一个符合 Python 语法的文本文件,也就是做一次"解包"。
1.3 转换的本质:从文档容器变成纯文本代码
转换的核心逻辑其实是三件事:
第一,遍历所有 cells,把cell_type=code的源码依次拼在一起,保持它们在 notebook 里的顺序。第二,把cell_type=markdown的内容变成注释,通常是逐行加#,这样既能保留分析思路,又不会干扰脚本运行。第三,丢弃outputs字段,因为脚本文件没有执行记录区。
你只要用几十行 Python 代码配合标准库 json 就能手工实现这个过程,很多公开的转换脚本本质就是这个思路。但现实中你不需要自己写,因为官方生态里已经有成熟的工具链,接下来四章分别介绍。
2. 不想装任何东西的话:用 Jupyter 自带的导出功能
2.1 点几下鼠标就能完成
如果你手里有 Jupyter Notebook 或者 JupyterLab 环境,最简单的方案就在界面里。打开目标 notebook,按照下面的路径操作:
- Jupyter Notebook 经典界面:
File→Download as→Python (.py) - JupyterLab 界面:
File→Save and Export Notebook As...→Python
点完之后浏览器会下载一个同名但后缀为 .py 的文件,整个过程不超过十秒钟。转换出来的文件内容非常规整,code 单元格就是源码,markdown 单元格被处理成带#的注释,单元格之间还有# In[n]:这样的窗口分隔标记。
这个功能背后调用的其实就是 nbconvert 的内核,所以输出质量很高,不是简单的字符串拼接。
2.2 这个方案的优缺点
它的优点只有一个:零配置、零学习成本,任何人打开 Jupyter 都能操作。但缺点也很明显:每次转换都需要手动打开浏览器点菜单,如果手里有几十个 notebook 要批量处理,点一百多下鼠标想想都崩溃。另一个局限是"下载"这个动作把文件存到了浏览器默认下载目录,不像命令行工具能精确控制输出位置。
所以我的建议是:文件数量少、只转一次、身边正好有 Jupyter 环境,用这个方案足够。但凡涉及批量、自动化、或者要嵌进自己的工作流里,直接跳到第三章的命令行方案。
2.3 一个很多人不知道的技巧:%save 魔法命令
如果你正在 Jupyter 里编辑 notebook,只想把某几个单元格导出成 .py,而不想要全部单元格,这里有个隐藏操作。在 notebook 里新建一个单元格,输入:
%save output_part.py 1 2 3-5这条魔法命令会把第 1 个、第 2 个、第 3 到第 5 个单元格保存到当前工作目录下的output_part.py文件。注意这里的编号是单元格的执行序号,不是它在 notebook 里的排列序号。你可以通过 In[序号] 左侧的标签判断。
这个技巧最大的价值是"精准导出",适合你要把一个 notebook 里的精华代码单独抽出来做工具模块的场景。缺点是会连带生成一些注释文本,而且不保留 markdown 单元格,至于格式美观度就别太指望了。
3. 命令行扛把子:jupyter nbconvert 的使用手册
3.1 一条命令完成单个文件转换
nbconvert 是 Jupyter 官方提供的文档转换工具,也是前面图形界面导出功能的后台引擎。只要你的环境里能跑jupyter命令,说明 nbconvert 已经包含了(它是 Jupyter 生态自带组件)。
单个文件转换的完整命令:
jupyter nbconvert --to script demo.ipynb执行完成后,当前目录会生成demo.py,终端还会输出一句执行日志。打开生成的 .py 文件,你会看到大概这种格式:
#!/usr/bin/env python # coding: utf-8 # In[1]: import pandas as pd # In[2]: print("hello")注意三点:
第一行是 shebang,方便在 Linux/macOS 下直接当脚本执行。# coding: utf-8声明编码,虽然 Python3 默认就是 UTF-8,但保留声明能避免老工具链误判。最显眼的是# In[1]这种标记,它对应原 notebook 里的单元格编号,相当于一种锚点注释,能帮你在代码和 notebook 之间建立对应关系。
这个转换是纯文本级别的操作,不执行任何代码,所以速度快、无副作用。哪怕 notebook 里的代码本身有语法错误,转换也会正常完成——它只是提取文本,不检查语法。
3.2 批量转换和输出文件控制
真正让 nbconvert 和图形界面拉开差距的,是它在批量和自动化场景下的灵活性。
一次转换目录下所有 notebook:
for f in *.ipynb; do jupyter nbconvert --to script "$f" done在 Windows PowerShell 里,对应写法是:
Get-ChildItem -Filter *.ipynb | ForEach-Object { jupyter nbconvert --to script $_.Name }如果想自定义输出文件名,用--output参数:
jupyter nbconvert --to script demo.ipynb --output my_script生成的文件就是my_script.py,而不是demo.py。还可以指定输出目录:
jupyter nbconvert --to script demo.ipynb --output-dir ./converted这几个参数组合起来,基本能满足任何批量处理需求。我自己平时最常用的是先批量转换,再配合 grep 检查生成的代码里有没有残留的魔法命令,相当于给转换结果过一次安检。
3.3 自定义模板:把转换结果敲干净
默认生成的 .py 里有#!/usr/bin/env python、# coding: utf-8、# In[1]这些注释。绝大多数情况下它们是无害的,甚至有利于定位代码来源。但如果你要把转换结果直接提交到公司代码仓库,或者分发给不太懂这些标记的同事,有些人会觉得它们碍眼。
这时候有两种处理思路。
第一种,接受这些标记。它们就是注释,不影响运行,运行时会自动忽略。
第二种,自己写一个 Jinja2 模板覆盖默认行为。nbconvert 的 script 转换支持自定义模板文件,比如创建一个clean.tpl:
{%- extends 'script.j2' -%} {%- block header -%} #!/usr/bin/env python {%- endblock header -%} {%- block in_markdown -%} {# 去掉 In[] 标记 #} {%- endblock in_markdown -%}然后指定模板转换:
jupyter nbconvert --to script demo.ipynb --template clean.tpl注意clean.tpl需要放在当前目录,nbconvert 会按相对路径找。这个做法的问题是模板语法得现学现查,对不是搞前端的人有点费神。我建议:除非团队有硬性代码格式要求,否则保留默认标记完全够用,核心代码逻辑一点都不会丢。
3.4 转换后的脚本能不能直接跑?
这是绕不开的问题。转换只是把文本搬了个家,能不能跑取决于 notebook 里的代码本身。有几种情况需要特别关注。
魔法命令。以%和%%开头的 IPython 魔法命令(比如%matplotlib inline、%timeit、%%capture)在普通 Python 脚本里很多是不能用的。转换器不会帮你删除或改写这些命令,原样放进 .py 里,运行时就会报错。
路径问题。notebook 里如果用了相对路径,比如pd.read_excel('data/xxx.xlsx'),转换后的脚本假设你从当前目录运行脚本,路径才能对上。如果你把 .py 挪到别的目录再跑,路径就断了。
输出丢失。notebook 里保存的图片、表格、交互组件这些输出不会进入 .py,因为脚本本身没有"输出记录区"。你只能重新运行代码,让 print 重新在终端输出。
转换后的脚本最常见的问题是魔法命令,这个在第五章统一讲怎么处理。
4. 长期使用的最优解:jupytext 双向同步
4.1 为什么单向转换不够用
前两章讲的方案本质上都是"一次性的单向转换"——构成了 .py 之后,你再修改 notebook,.py 不会自动跟着变。反过来,如果你在 IDE 里改了 .py,notebook 里的代码也不受影响。这意味着同一个项目里出现两份代码,改着改着就分叉了。
对于长期项目来说,这非常痛苦。你会有选择困难症:改哪个?以哪个为准?git 提交的时候两个文件都变动了,reviewer 看哪份?我见过不少项目因为维护两套代码导致手动同步遗漏,最后爆出"线上跑的代码和 notebook 里根本不是一回事"的问题。
解决思路是让 .ipynb 和 .py 配对存在,一方修改另一方自动同步。这就是 jupytext 干的事。
4.2 安装和两种实用的工作流
jupytext 是一个开源工具,安装非常简单:
pip install jupytext装完推荐使用第一种工作流:一次性转换,效果类似 nbconvert,但生成的 .py 更干净:
jupytext --to py demo.ipynb生成的demo.py顶部会多一个 YAML 元数据头,类似:
--- jupyter: jupytext: formats: ipynb,py text_representation: extension: .py ---这个头是 jupytext 识别配对关系的关键,别手欠删掉,删了之后就失去双向同步能力了。
第二种工作流是配对模式,分两步走。先设置配对格式并同步:
jupytext --set-formats ipynb,py --sync demo.ipynb这会在当前目录生成demo.py,同时给demo.ipynb的 metadata 写入 jupytext 配置。之后每次你保存 notebook,对应的 .py 会自动更新。反过来,你直接用编辑器修改 .py,再执行同步命令:
jupytext --sync demo.py对应的 .ipynb 也会跟着变。这个"保存即同步"的效果,需要让 Jupyter 使用 jupytext 的文件管理器才会真正顺手,具体做法是编辑 Jupyter 配置文件jupyter_notebook_config.py,加上两行:
c.NotebookApp.contents_manager_class = "jupytext.TextFileContentsManager" c.ContentsManager.default_jupytext_formats = "ipynb,py"加了之后,Jupyter 里所有新建的 notebook 都会自动生成配对 .py 文件,保存时自动同步。对于以文本编辑器为主场的开发者,这个配置能让 Git 世界里的代码审查回到一个舒服的节奏。
4.3 我在项目里是怎么用 jupytext 的
分享一个实际项目经验。团队里有人习惯用 notebook 做数据分析原型,但生产环境的调度框架只能跑 .py。以前是 notebook 完事之后手工导出 .py,再手动改魔法命令、改路径,每次发布都心惊胆战。
后来配了 jupytext:开发者在 notebook 里写代码,保存即同步 .py;运维从 git 拉下代码用 .py 部署;评审 diff 直接用 .py 看,逻辑清晰没有 JSON 噪音。整个流程跑下来,最大感受是"再也用不着'导出'这一步了",两边始终是同一份代码的两种表现形式。
jupytext 还支持把 notebook 映射成 Markdown 或 R 脚本,原理一样,如果你有跨语言协作需求可以查阅官方文档,但这篇文章聚焦 Python,不多展开。
5. 转换过程中的高频坑和不传经验
5.1 魔法命令在 .py 里失效
这是转换后报错频率最高的原因,几乎每个用过%matplotlib的人都会遇到。
看一下常见魔法命令在纯 Python 脚本里的命运:
| 魔法命令 | 作用 | 转成 .py 后的表现 |
|---|---|---|
%matplotlib inline | 让图表显示在 notebook 内 | 脚本中无效,需改为plt.show() |
%timeit | 计时单行语句 | 直接报错,建议用timeit模块替代 |
%%time | 计时整个单元格 | 直接报错,换成time模块 |
%%capture | 捕获单元格输出 | 直接报错,需要删除后重新设计输出逻辑 |
!ls | 执行 shell 命令 | 可以运行,但注意路径和输出格式 |
%load_ext autoreload | 自动加载外部模块 | 脚本里可保留,不报错 |
处理策略分两种。一种在转换前就在 notebook 里少用魔法命令,把%matplotlib inline直接删掉,用plt.show()替代。另一种是转换后全局搜索%开头的行,逐行评估,能删则删,能换则换。
grep -n "^%" output.py这条命令快速列出所有带魔法命令的行号,处理完再运行,基本就没有这块的报错了。
5.2 中文字符编码问题
如果你在国内 Windows 环境工作,转换后的 .py 文件用默认的 cp936(GBK)编码读取时,遇到 emoji 或生僻字可能直接抛UnicodeDecodeError。Jupyter 生成的 .py 是 UTF-8 编码,而老版本 Windows 控制台默认编码不是 UTF-8。
排查思路:先用文本编辑器打开看有没有乱码,如果文件本身是好的,那就是运行环境的问题。临时解决办法是脚本开头加:
import sys import io sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')或者转换后统一把编码转成 GBK 也是办法。不过现在 Python3.7+ 和 Windows 新版系统对 UTF-8 的支持已经好很多,建议优先升级环境,而不是改文件编码。
5.3 输出结果不见了?这不是 bug
很多人在转换后跑脚本,发现"原来 notebook 里的 result 图形没出现"。这是正常行为,因为 .py 文件不保存任何执行结果,它只有源码。notebook 里的图表、DataFrame 预览、交互空间在转换时全部丢弃。
能做的不是在转换工具里找选项,而是在代码里把结果显式输出。比如原来 notebook 里最后一格只有一个变量名df就可以显示表格,在 .py 里你得手动加print(df)才能看到同样内容。图表同理,必须调用plt.show()或fig.savefig()。
5.4 乱序执行依赖:这个坑最隐蔽
notebook 允许你跳着执行单元格,比如先跑后面的代码,再回来跑前面的。如果开发者在交互式探索中大量使用这种乱序执行,最终 notebook 的执行状态和单元格顺序是不一致的。
转换后的 .py 是按照单元格排列顺序从上到下执行的。如果 A 单元格依赖 B 单元格产生的变量,而 A 在 B 前面,脚本一开始就报NameError。
我自己处理这个问题的方法是:先重启内核,然后Run All从头跑一遍 notebook,确认能顺利通过,再做转换。这个"跑通则转"的习惯能规避九成以上乱序依赖问题。如果重启后Run All本来就跑不通,那问题不在转换,先把 notebook 改成顺序可执行状态再转。
5.5 转换问题速查表
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 改后缀后 Python 报语法错误 | .ipynb 是 JSON 不是源码 | 用 nbconvert 或 jupytext 转换 |
生成的 .py 运行报Magic command not found | 魔法命令不被 Python 支持 | 删除或替换为等价标准库代码 |
| 图表一片空白或者说没有显示 | notebook 输出不保存在 .py | 在代码里加plt.show()或保存图片 |
| 中文字符乱码或编码异常 | UTF-8 与本地默认编码不一致 | 设置环境编码或在脚本开头声明 UTF-8 |
脚本一启动就NameError | notebook 乱序执行依赖 | 先重启内核 Run All 通过后再转换 |
| 转换后的文件路径找不到数据 | 相对路径随脚本位置变化 | 统一使用绝对路径或以脚本位置定位 |
| 每次想要 .py 都得手动导出 | 工作流不够自动化 | 换 jupytext 配对模式 |
6. 到底该选哪种方案?结合场景的选型建议
看了上面四套方案,你可能会有点选择困难。其实没有绝对的优劣,只看场景,我按平时接触最多的情况给个参考。
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 偶发一次,手上开着 Jupyter | 图形界面导出 | 零成本、零记忆负担 |
| 批量转换几十个 old notebook | nbconvert 命令行 | 一条命令处理所有文件 |
| 将 notebook 部署进 CI/CD 流水线 | nbconvert 配合脚本 | 可自动化、可参数化 |
| 长期维护的 Python 项目,想要代码双份同步 | jupytext 配对模式 | 保存即同步,免去手动导出 |
| 只抽取 notebook 中部分代码做参考 | %save或手工复制 | 精准控制范围 |
再补充一个额外的判断维度:你要的是"结果文件"还是"工作流"。如果只是今天需要一个 .py 临时用,那选最简单的方式就行;如果你发现 notebook 和脚本的同步问题已经困扰你超过两周,说明需要从工作流层面换取 jupytext 的长期机制。
6.1 我的个人体会
用了那么多年 notebook 和脚本,我最深的感受是:.ipynb 和 .py 不是竞争关系,它们是同一个项目的两种不同视图。notebook 更适合探索、演示、教学,.py 更适合工程化、自动化、版本对比。转换不是"抛弃 notebook 投奔 py",而是让代码在合适的生命周期阶段出现在合适的载体里。
在 jupytext 出来之前,我都是到项目发布前才匆匆忙忙导一次 .py,导完还要手动清理。现在配好配对模式之后,这份牵扯精力已经彻底没了。建议每个经常用 notebook 的开发者都花十分钟试试 jupytext,配置一次,后面省心很多。
最后分享一个小经验:无论用哪种方案,转换完成后第一件事永远是打开生成的 .py 文件从头到尾读一遍——不是看代码逻辑对不对,而是看有没有魔法命令、路径硬编码、或者明显的转换垃圾。这五分钟的检查,往往能省下后面好几个小时的排障时间。