PyCon-2021-会议笔记-全-

PyCon 2021 会议笔记(全)

001:Python 数据类的运行时类型检查 🧑‍💻

在本教程中,我们将学习 Pydantic 库。Pydantic 是一个 Python 库,它利用 Python 的类型注解在运行时强制执行数据验证和设置管理。我们将从回顾 Python 数据类开始,逐步探索 Pydantic 的核心功能,包括数据验证、JSON 序列化、自定义验证器以及与 Web 框架的集成。


Python 数据类快速回顾 📝

在深入了解 Pydantic 之前,我们先快速回顾一下 Python 的数据类。数据类(@dataclass)是 Python 3.7+ 引入的一个装饰器,用于自动生成特殊方法(如 __init____repr__)的类,非常适合用来存储数据。

假设我们经营一家名为 WaffleBistro 的华夫饼店,我们需要对华夫饼订单进行建模。一个简单的数据类可能如下所示:

from dataclasses import dataclass
from typing import List

@dataclass
class Waffle:
    style: str
    toppings: List[str]

我们可以轻松创建华夫饼对象:

waffle = Waffle(style="Swedish", toppings=["chocolate sauce", "ham"])

然而,这个模型存在一个问题:styletoppings 被定义为字符串列表,这意味着我们可以传入任何字符串值,即使它不符合业务逻辑(例如,在华夫饼上加火腿)。静态类型检查器(如 mypy)可能会在开发时捕获类型错误,但在运行时,程序不会阻止这种无效数据的创建。

为了解决这个问题,我们可以尝试使用枚举(Enum)来限制可选值:

from enum import Enum
from dataclasses import dataclass
from typing import List

class Topping(Enum):
    WHIPPED_CREAM = "whipped cream"
    ICE_CREAM = "ice cream"

class Sauce(Enum):
    CLOUDBERRY_JAM = "cloudberry jam"
    RASPBERRY_JAM = "raspberry jam"
    CHOCOLATE_SAUCE = "chocolate sauce"

class WaffleStyle(Enum):
    SWEDISH = "Swedish"
    BELGIAN = "Belgian"

@dataclass
class Waffle:
    style: WaffleStyle
    toppings: List[Union[Topping, Sauce]]

尽管使用了枚举,Python 数据类在运行时仍然不会强制执行这些类型约束。以下代码不会引发错误:

# 这仍然可以创建,尽管火腿不是有效的 Topping 或 Sauce
invalid_waffle = Waffle(style=WaffleStyle.SWEDISH, toppings=["ham"])

因此,我们需要一种在运行时进行类型检查的机制。这就是 Pydantic 的用武之地。


引入 Pydantic ✅

Pydantic 是一个库,它允许你在运行时强制执行 Python 类型注解。它与数据类兼容,但功能更强大。Pydantic 提供友好的错误信息、内置序列化支持,并且完全基于标准的 Python 类型注解,没有特殊的语法要求。

Pydantic 有几个关键优势:

  • 运行时类型检查:确保数据符合定义的模型。
  • 数据解析与转换:自动将输入数据(如 JSON、字典)转换为正确的 Python 类型。
  • 易于使用:只需继承 BaseModel 或使用 @pydantic.dataclasses.dataclass 装饰器。
  • 出色的文档:拥有全面且清晰的文档。

现在,让我们看看如何用 Pydantic 解决华夫饼的问题。


运行时类型检查 🔍

上一节我们看到了纯数据类在运行时验证上的不足。本节中,我们来看看如何使用 Pydantic 在创建对象时进行类型检查。

首先,我们需要从 Pydantic 导入数据类装饰器:

from enum import Enum
from typing import List, Union
from pydantic.dataclasses import dataclass

# 使用之前定义的 Enum: Topping, Sauce, WaffleStyle

@dataclass
class Waffle:
    style: WaffleStyle
    toppings: List[Union[Topping, Sauce]]

现在,当我们尝试用无效数据创建 Waffle 对象时,Pydantic 会在运行时抛出一个清晰的验证错误:

try:
    invalid_waffle = Waffle(style=WaffleStyle.SWEDISH, toppings=["ham"])
except Exception as e:
    print(e)

输出示例

ValidationError: 2 validation errors for Waffle
toppings -> 0
  value is not a valid enumeration member; permitted: <Topping.WHIPPED_CREAM: 'whipped cream'>, <Topping.ICE_CREAM: 'ice cream'>, <Sauce.CLOUDBERRY_JAM: 'cloudberry jam'>, <Sauce.RASPBERRY_JAM: 'raspberry jam'>, <Sauce.CHOCOLATE_SAUCE: 'chocolate sauce'> (type=type_error.enum)

错误信息明确指出了哪个字段(toppings 的第 0 个元素)出了问题,以及允许的值有哪些。Pydantic 还会尝试解析数据,例如,如果你传入字符串 "whipped cream",它会自动转换为 Topping.WHIPPED_CREAM 枚举成员。

# Pydantic 自动将字符串解析为对应的枚举
valid_waffle = Waffle(style="Swedish", toppings=["whipped cream", "cloudberry jam"])
print(valid_waffle)
# 输出: Waffle(style=<WaffleStyle.SWEDISH: 'Swedish'>, toppings=[<Topping.WHIPPED_CREAM: 'whipped cream'>, <Sauce.CLOUDBERRY_JAM: 'cloudberry jam'>])

使用 BaseModel 与 JSON 支持 📄

虽然 Pydantic 的数据类很好用,但直接继承 BaseModel 类能提供更多功能,尤其是一流的 JSON 支持。

让我们将 Waffle 类改为继承自 BaseModel

from pydantic import BaseModel
from enum import Enum
from typing import List, Union

# ... 枚举定义同上 ...

class Waffle(BaseModel):
    style: WaffleStyle
    toppings: List[Union[Topping, Sauce]]

    class Config:
        # 允许使用枚举值(字符串)进行实例化
        use_enum_values = True

创建对象的方式类似,但通常使用关键字参数:

waffle = Waffle(style=WaffleStyle.SWEDISH, toppings=[Topping.WHIPPED_CREAM, Sauce.CLOUDBERRY_JAM])

BaseModel 的强大之处在于其内置的序列化方法。以下是如何将对象转换为 JSON 以及从 JSON 重建对象:

# 序列化为 JSON 字符串
json_str = waffle.json()
print(json_str)
# 输出: {"style": "Swedish", "toppings": ["whipped cream", "cloudberry jam"]}

# 从 JSON 字符串反序列化(解析)为对象
reconstructed_waffle = Waffle.parse_raw(json_str)
print(reconstructed_waffle)
# 输出: style='Swedish' toppings=['whipped cream', 'cloudberry jam']

当验证失败时,Pydantic 提供了结构化的错误信息,非常适合 API 开发:

try:
    invalid = Waffle.parse_raw('{"style": 42, "toppings": ["ham"]}')
except Exception as e:
    error_dict = e.json()
    print(error_dict)

输出示例(格式化后):

[
  {
    "loc": ["style"],
    "msg": "value is not a valid enumeration member; permitted: 'Swedish', 'Belgian'",
    "type": "type_error.enum"
  },
  {
    "loc": ["toppings", 0],
    "msg": "value is not a valid enumeration member; permitted: 'whipped cream', 'ice cream', 'cloudberry jam', 'raspberry jam', 'chocolate sauce'",
    "type": "type_error.enum"
  }
]

自定义验证器与业务逻辑 ⚙️

内置的类型检查很强大,但现实中的业务规则往往更复杂。Pydantic 允许你定义自定义验证器来实施这些规则。

假设 WaffleBistro 有以下业务规则:

  1. 瑞典风格华夫饼只允许搭配果酱(Sauce)。
  2. 比利时风格华夫饼允许搭配巧克力酱。
  3. 一份华夫饼不能同时包含冰淇淋和打发奶油。

我们可以通过创建一个继承自 WaffleWaffleOrder 类,并添加根验证器来实现这些规则:

from pydantic import BaseModel, validator, root_validator
from typing import List, Union

class WaffleOrder(Waffle): # 继承自之前的 Waffle(BaseModel)
    @root_validator(pre=False, skip_on_failure=True)
    def check_order_rules(cls, values):
        style = values.get('style')
        toppings = values.get('toppings', [])

        # 规则 1 & 2: 检查风格与酱料的搭配
        if style == WaffleStyle.SWEDISH:
            for topping in toppings:
                if isinstance(topping, Sauce) and topping == Sauce.CHOCOLATE_SAUCE:
                    raise ValueError('WaffleBistro does not sell Swedish waffles with chocolate sauce.')
        # 注意:比利时风格允许巧克力酱,所以无需额外检查

        # 规则 3: 检查奶油类配料的数量
        cream_toppings = [t for t in toppings if isinstance(t, Topping)]
        if len(cream_toppings) > 1:
            raise ValueError('Only one cream topping (whipped cream or ice cream) is allowed.')

        return values

现在,让我们测试这些验证器:

# 有效的订单
order1 = WaffleOrder(style="Swedish", toppings=["whipped cream", "cloudberry jam"])
print("Order 1 valid:", order1)

# 无效的订单:同时包含冰淇淋和奶油
try:
    order2 = WaffleOrder(style="Belgian", toppings=["ice cream", "whipped cream", "chocolate sauce"])
except ValueError as e:
    print("Order 2 error:", e)

# 无效的订单:瑞典华夫饼配巧克力酱
try:
    order3 = WaffleOrder(style="Swedish", toppings=["whipped cream", "chocolate sauce"])
except ValueError as e:
    print("Order 3 error:", e)

函数参数验证 🛠️

除了验证类,Pydantic 还可以验证函数参数。validate_arguments 装饰器(在 Pydantic 1.5+ 中引入)可以自动验证传递给函数的参数。

from pydantic import validate_arguments
from typing import Union

@validate_arguments
def make_waffle_order(order: WaffleOrder) -> str:
    # 业务逻辑:处理订单
    return f"Order received: {order.style} waffle with {', '.join([str(t) for t in order.toppings])}"

# 有效的调用
result = make_waffle_order({"style": "Belgian", "toppings": ["ice cream", "chocolate sauce"]})
print(result) # 字典会被自动解析为 WaffleOrder 对象

# 无效的调用会抛出验证错误
try:
    make_waffle_order({"style": "Breakfast", "toppings": ["ham"]})
except Exception as e:
    print(e)

框架集成 🌐

Pydantic 与许多流行的 Python Web 框架有着良好的集成,这使得它在构建 API 时特别有用。

以下是支持 Pydantic 的部分框架:

  • FastAPI:深度集成,用于请求和响应模型,自动生成 API 文档。
  • Starlette:通过 pydanticrequests/responses 集成。
  • Flask:可通过扩展(如 flask-pydantic)或手动使用。
  • Django Ninja:为 Django 提供类似 FastAPI 的体验。
  • Quart-Schema:为异步框架 Quart 提供 Pydantic 集成。

一个使用 FastAPI 的简单示例如下:

from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class WaffleOrder(BaseModel):
    style: str
    toppings: List[str]

@app.post("/order/")
async def create_order(order: WaffleOrder):
    # `order` 参数已经过 Pydantic 验证
    # 处理订单的业务逻辑...
    return {"message": "Order placed!", "order_details": order.dict()}

@app.get("/order/{order_id}")
async def get_order(order_id: int):
    # 获取订单逻辑...
    return {"order_id": order_id}

FastAPI 会自动为这个端点生成交互式 API 文档(Swagger UI),并且所有传入 create_order 的数据都会根据 WaffleOrder 模型进行验证。


其他实用功能与总结 🎯

Pydantic 还有许多其他有用的功能:

  • JSON Schema 生成:可以使用 WaffleOrder.schema()WaffleOrder.schema_json() 为模型生成 JSON Schema,这对于生成 OpenAPI 文档非常有用。
  • 设置管理:Pydantic 的 BaseSettings 类非常适合管理应用程序配置,可以从环境变量、文件等来源加载设置。
  • 严格模式:正在开发中的功能,将提供更严格的类型强制(例如,禁止字符串到枚举的自动转换)。
  • 性能:Pydantic 的核心部分用 Cython 编写,速度很快。
  • 测试支持:可以与 hypothesis 等测试库配合,进行基于属性的测试。

总结

在本教程中,我们一起学习了 Pydantic 库的核心概念。我们从 Python 数据类的局限性出发,看到了 Pydantic 如何通过运行时类型检查来解决这些问题。我们探索了如何使用 BaseModel 进行数据验证和 JSON 序列化,如何编写自定义验证器来实现复杂的业务逻辑,以及如何验证函数参数。最后,我们了解了 Pydantic 如何与 FastAPI 等 Web 框架无缝集成,极大地简化了 API 的开发和数据验证工作。

Pydantic 的核心优势在于它使用纯 Python 类型注解,提供了强大的运行时验证、友好的错误提示和出色的工具链集成。无论是小型脚本还是大型生产应用,它都是一个非常有价值的工具。建议你通过实际项目来尝试使用 Pydantic,以充分体验其便利性。

002:重启 Pyjion,一个通用的 Python JIT 编译器

概述

在本教程中,我们将学习 Pyjion 项目。这是一个为 CPython 设计的即时(JIT)编译器,旨在提升 Python 代码的执行速度,同时保持与标准 CPython 的完全兼容性。我们将探讨 Python 性能的常见瓶颈、JIT 编译的基本原理,并了解 Pyjion 如何通过优化字节码执行来工作。


Python 性能挑战与 JIT 的潜力

上一节我们概述了 Pyjion 的目标。现在,让我们深入了解 Python 在性能上面临的一些具体挑战,以及为何 JIT 编译可能是一个解决方案。

在 2020 年的 PyCon US 演讲中,我探讨了“为什么 Python 这么慢?”的问题。其中一个关键基准测试是 N-Body 算法,它用于计算行星轨道。Python 在这个算法上的表现远不如 C、Ruby、PHP、Perl,甚至 JavaScript(Node.js)。尽管 JavaScript 同样是动态语言、拥有垃圾回收(GC)和全局解释器锁(GIL),但其执行速度却快得多。

以下是 Python N-Body 实现的核心代码段:

# 假设这是计算的一部分
result = (x * y) + (a * b) - (c ** d)

Python 会严格按照操作顺序执行字节码。但问题在于,每次乘法、加法或幂运算的结果都会产生一个新的浮点数对象。这个对象被创建后,几乎立即在下一个操作中被引用,然后因引用计数归零而被释放。在密集循环中,这种临时对象的频繁分配和释放会消耗大量时间。

如果使用 Cython 将变量注解为 double 类型,执行速度可以提升约 8 倍。这是因为浮点数可以保留在 CPU 寄存器中,避免了堆内存操作。其他 JIT(如 PyPy)也有机制来消除这些临时对象的开销。

总结 Python 慢速的几个原因:

  • 临时对象问题:在紧密循环中频繁创建和销毁对象。
  • CPython 评估循环开销:解释执行字节码本身有成本。
  • 兼容性代价:许多性能优化方案会牺牲兼容性或平台支持。
  • 垃圾回收器:CPython 使用的是“停止一切(Stop-The-World)”的垃圾回收器。

理论上,一个专门的 JIT 编译器可以在某些情况下提供帮助。


现有的 Python JIT 方案

上一节我们分析了 Python 的性能瓶颈。本节中,我们来看看目前社区中已有的一些 JIT 解决方案及其特点。

目前存在多个为 Python 代码添加 JIT 编译的项目:

  • Numba:主要针对数据科学领域。它是一个装饰器,可以对带有类型注解的特定函数进行 JIT 编译,以优化 NumPy 调用。对于纯 Python 代码可能没有帮助,甚至可能变慢。
  • Pyston:CPython 的一个分支,集成了使用 LLVM 的 JIT 引擎。它声称能带来 10% 到 20% 的性能提升。缺点是它是闭源项目,并且是一个需要单独部署的运行时。
  • PyPy:一个用 Python 编写的 Python 解释器,拥有成熟的 JIT,在许多场景下能带来巨大性能提升。缺点是某些情况下可能比 CPython 慢,且与部分 C 扩展的兼容性不佳。

我认为,目前仍然缺少一个专注于兼容性、并能弥补 CPython 评估循环不足的通用 JIT。这就是 Pyjion 项目的目标。


Pyjion 项目介绍

上一节我们回顾了现有的 JIT 方案。本节我们将聚焦于 Pyjion 本身,了解它的定位、设计目标和兼容性承诺。

Pyjion 是一个用于 CPython 字节码的 JIT 编译器。你可以通过 pip 安装,它与 CPython 3.9 兼容,支持 Linux、macOS 和 Windows 的 64 位 Intel CPU 架构。

需要明确几点:

  • Pyjion 不是另一个 Python 解释器,它在 CPython 3.9 内部运行。
  • Pyjion 不是全新项目,它最初在 2016 年 PyCon 上提出。我在过去九个月里基本上重写了它,但设计理念相似。

Pyjion 的设计目标:

  1. 专注于兼容性:在 CPython 中能运行的代码,在启用 Pyjion 后也应该能运行。
  2. 最小化 JIT 启动开销:不能像 Java 虚拟机那样有漫长的启动时间。
  3. 无需代码更改:除了启用 JIT,不应要求添加类型注解或装饰器。
  4. 易于部署:应能通过 pip install 安装并导入,适用于各种环境。

Pyjion 的工作原理

上一节我们介绍了 Pyjion 的目标。本节中,我们来看看 Pyjion 是如何集成到 CPython 的执行流程中并发挥作用的。

要理解 Pyjion,首先需要了解 CPython 的编译和执行过程:

  1. 解析:Python 代码被解析器转换为抽象语法树(AST)。
  2. 编译:编译器将 AST 转换为字节码(.pyc 文件)。
  3. 评估:CPython 虚拟机遍历并执行这些字节码。

Pyjion 将自身插入到编译评估阶段之间。它在运行时将 Python 字节码重新编译为本地机器码。具体过程是:当一个函数运行一定次数后,Pyjion 会将其字节码编译成一种称为 ECMA CIL 的中间语言,然后利用 .NET 5 的 JIT 编译器将其最终编译为机器码(汇编指令)。编译后的代码被缓存到内存中供后续执行。

选择 .NET 5 JIT 是一个实现细节,它提供了成熟的、跨平台的编译后端,但用户代码与 .NET 本身无关。


使用 Pyjion:一个简单示例

上一节我们从理论上解释了 Pyjion 的工作原理。本节我们通过一个具体的代码示例,看看如何安装和使用它。

以下是使用 Pyjion 的步骤:

首先,在 Python 3.9 环境中安装 Pyjion:

pip install pyjion

然后,在 Python REPL 或脚本中启用并使用它:

import pyjion
pyjion.enable()  # 启用 JIT 编译器

def half(x):
    return x / 2

# 第一次调用会触发 JIT 编译
result = half(10)
print(result)  # 输出 5.0

启用 Pyjion 后,当 half 函数被调用时,JIT 编译器会启动,并将该函数的字节码编译为机器码。你可以使用 pyjion.info(function) 来查看函数的 JIT 编译状态,或者使用 pyjion.dis.dis(function) 来反汇编生成的中间语言(CIL)代码。


Pyjion 的实际应用与优化

上一节我们运行了一个简单的示例。本节我们看看如何将 Pyjion 集成到更复杂的实际项目中,并了解它目前所做的优化。

Pyjion 可以轻松集成到 Web 框架中,例如 Flask。以下是一个简单的 Flask 应用示例,它通过 Pyjion 提供的 WSGI 中间件启用 JIT:

from flask import Flask
from pyjion.middleware import PyjionMiddleware

app = Flask(__name__)
app.wsgi_app = PyjionMiddleware(app.wsgi_app)  # 启用 JIT 中间件

@app.route('/')
def hello():
    return 'Hello, World!'

if __name__ == '__main__':
    app.run()

添加中间件后,Flask 应用的所有路由函数都将被 JIT 编译,而无需对现有代码做任何修改。

然而,仅仅将字节码编译为机器码并调用相同的 CPython C API,并不会自动带来性能提升。性能提升来自于 JIT 优化。Pyjion 会在编译时识别代码模式,并生成更高效的机器码。目前已经实现了一些优化,例如:

  • 常量索引访问优化:对于 list[0] 这样的操作,直接使用 C API 的快速路径。
  • 临时对象消除:对于连续的浮点数运算,将中间结果保留在 CPU 寄存器中,避免创建临时 Python 对象。

这些优化虽然看起来不大,但它们是许多 Python 代码中的常见模式,因此能带来广泛的性能收益。


未来方向:配置文件引导编译(PGC)

上一节我们看到了 Pyjion 当前的优化。本节我们探讨一个正在开发中的关键特性,它旨在解决 Python JIT 编译的最大挑战之一——类型推断。

Python 是动态类型语言,变量的类型在运行时才能确定。这对优化构成了巨大挑战。Pyjion 正在开发的 配置文件引导编译(PGC) 功能就是为了解决这个问题。

PGC 的工作流程:

  1. 分析阶段:当一个函数首次被 JIT 编译后,Pyjion 会在生成的代码中插入“探针”,记录执行过程中关键位置的操作数类型。
  2. 优化再编译:当函数再次被执行,并且积累了足够的分析数据后,Pyjion 会利用这些类型信息重新编译该函数。例如,如果发现某个变量总是字符串,就可以针对字符串操作进行优化。
  3. 类型保护:为了应对类型可能变化的情况,优化后的代码会包含运行时类型检查。如果类型不符,则回退到通用的、未优化的执行路径。

你可以通过 pyjion.info(function) 查看函数的 PGC 状态(如 未编译已分析已优化)。


性能现状与总结

上一节我们展望了 Pyjion 的未来特性。在本节最后,我们来看看它目前的性能表现,并对整个教程进行总结。

目前,在 N-Body 基准测试上,Pyjion 相比 CPython 3.9 取得了约 30% 的执行时间减少。在其他基准测试(如 fannkuchfloat)中,也有约 20% 的性能提升。我们的理念是,任何低于 20% 的优化努力可能都不够经济,因此目标是实现更大的性能飞跃。

总结
在本教程中,我们一起学习了:

  1. Python 性能的常见瓶颈,特别是临时对象和评估循环开销。
  2. 现有 Python JIT 方案(如 Numba、Pyston、PyPy)的特点与局限。
  3. Pyjion 项目:一个专注于兼容性、无需代码更改、易于部署的通用 CPython JIT 编译器。
  4. Pyjion 的工作原理,即运行时将字节码编译为机器码。
  5. 如何安装、启用 Pyjion,并将其集成到实际应用(如 Flask)中。
  6. Pyjion 当前实现的优化策略。
  7. 未来的发展方向——配置文件引导编译(PGC),用于动态类型推断和优化。

Pyjion 是一个有趣且充满潜力的项目,它试图以可插拔模块的形式为 CPython 带来透明的性能优化。如果你对编译器或 Python 性能优化感兴趣,欢迎参与贡献。同时,也请务必评估 PyPy,它已经是一个极其成熟且强大的 JIT 实现,可能非常适合你的项目。

资源链接

003:大规模Python性能优化实践

在本教程中,我们将学习Instagram团队如何通过一系列优化手段,显著提升其大规模Python(Django)应用的性能。我们将深入探讨他们对CPython的定制分支(Cinder)所做的改进、实验性工作以及最终的成果。


概述:Instagram的技术栈与性能分析

Instagram是一个运行在Django上的单体Web应用,使用Python 3.8,并以UWSGI作为Web服务器。UWSGI采用主进程(父进程)管理多个工作进程(子进程)的模型来处理请求。

为了提升性能,团队使用Linux Perf采样分析器对工作负载进行了深入分析,重点关注在90%服务器负载下的每秒请求数(RPS)这一核心指标。这个指标虽然会随时间波动,但对于衡量单次优化的效果非常有效。


核心优化:Cinder分支的成功改进

上一节我们介绍了Instagram的技术栈和分析方法,本节中我们来看看他们对CPython分支Cinder所做的几项关键优化。

利用UWSGI进程模型:不朽对象

UWSGI的父进程与子进程会共享大量内存。为了最大化共享、减少因写入导致的“写时复制”,团队修改了引用计数机制。

核心概念:将分叉前堆中的对象标记为“不朽”,使其在子进程中永远不会被释放。这需要修改Py_INCREFPy_DECREF宏,避免更新这些对象的引用计数。

// 简化概念:在分叉前,遍历堆并将对象标记为不朽
mark_objects_immortal(heap);

这项优化在生产环境中带来了约5%的RPS提升。

异步I/O优化

异步I/O是性能优化的重点领域。

  1. 避免StopIteration异常:在异步函数中,使用StopIteration传递值会创建异常对象,带来开销。团队优化了相关机制,在简单基准测试中获得了1.6倍的性能提升。此项改进已并入Python 3.10,并为生产环境带来额外5%的RPS收益。

  2. 急切求值(Eager Evaluation):许多异步调用会同步完成。团队优化了事件循环,对于能立即完成的操作,避免创建协程对象,而是返回一个单例等待句柄。结合asyncio.gather的新向量调用标志,这项优化带来了3%的RPS收益。

字节码内联缓存

团队为字节码引入了内联缓存机制。热门方法会获得一个包含优化后字节码和缓存数据结构的“影子副本”。

工作原理:当遇到可优化的操作码(如属性加载)时,将其替换为更具体的操作码。新操作码能快速进行类型检查和方法派发,绕过完整的动态查找过程。

以下是部分被优化的操作码及其替换目标:

  • LOAD_ATTR:优化属性查找,涵盖方法、实例字典、分裂字典等多种情况。
  • STORE_ATTR:优化属性存储,情况相对简单。
  • LOAD_GLOBAL:优化全局变量和内置函数查找。
  • BINARY_SUBSCR:优化下标操作。

这项优化整体带来了约5%的RPS提升。

全局变量查找优化:字典版本观察者

为了加速LOAD_GLOBAL,团队实现了“字典版本观察者”机制。

核心概念:为函数中使用的每个全局变量建立独立的缓存点。当模块或内置字典被修改时,对应的观察者会更新这些缓存。通过复用字典版本标签的低位来标记被观察的字典,实现了低开销的更新机制。

# 概念示例:函数内全局变量的缓存
# 内置字典观察者 -> 缓存 `min`, `len` 等
# 模块字典观察者 -> 缓存模块内定义的 `x`, `max` (可能覆盖内置)

将此机制与影子字节码结合,在原有优化基础上额外带来了5%的性能提升。

其他针对性优化

  1. __builtins__处理:移除了CPython中关于__builtins__的一个实现细节限制,获得了约1%的RPS提升。
  2. PyType查找优化:对类型对象查找进行了微优化,在某些基准测试中提速达1.19倍。此项改进已并入Python 3.10。
  3. 减少线程状态查找:避免了运行时的线程状态查找开销,改进已上游化。
  4. 内存预取:在框架创建时预加载属性,减少内存访问延迟。
  5. 构建系统优化
    • 基于生产数据的PGO:使用来自线上生产主机的性能数据,而非标准测试集,来指导基于配置文件的优化,使生成的二进制布局更优。
    • BOLT优化器:使用BOLT工具进一步优化二进制布局。
    • 巨页(Huge Pages):将UWSGI二进制文件迁移到巨页上,减少指令缓存未命中,带来了约3%的收益。

实验性工作:探索未来

上一节我们介绍了已投入生产的稳定优化,本节中我们来看看一些仍在探索中的实验性项目。

自定义JIT编译器

Cinder开发了一个按需编译的JIT编译器,覆盖了绝大多数操作码。

工作流程

  1. 前端:将字节码转换为高级中间表示(HIR),进行单静态赋值(SSA)等转换,并运行引用计数插入等优化传递。
  2. 后端:将HIR转换为低级中间表示(LIR),进行寄存器分配和针对性优化(如直接调用已知函数),最终通过JIT引擎生成x64机器码。

这种模式在特定基准测试(如Richards)中显示出显著的性能提升。

静态Python

静态Python旨在提供类似MyPy-C或Cython的类型性能,但允许直接运行普通的.py文件。

核心特性

  • 静态导入:通过from __static__ import ...启用静态编译和类型注解。
  • 原生类型:支持如int64等原生类型,允许进行高效的原始整数运算。
  • 新字节码:引入如CALL_FUNCTIONLOAD_FIELD等静态操作码,直接操作类型描述符。
  • 类型安全:在静态代码与普通Python代码边界强制执行类型检查。
  • 编译器:使用基于Python 3的编译器包,完全用Python编写。

from __static__ import int64

def compute(a: int64, b: int64) -> int64:
    return a * b  # 可编译为高效的机器指令

Pyro:实验性运行时

Pyro是一个从零开始编写的实验性Python运行时,旨在重用CPython标准库。

主要区别

  • 紧凑型垃圾回收
  • 类型指针:允许将整数等基本类型视为非对象。
  • 隐藏类:为属性访问提供高效的内联缓存支持。
  • C扩展支持:通过PEP 384 C API子集支持C扩展。

目前面临PEP 384适配和API模拟性能等挑战。


成果总结与未来展望

本节课中我们一起学习了Instagram在大规模Python性能优化上的综合实践。

性能成果总结
通过不朽对象、异步I/O优化、字节码内联缓存、全局查找优化、构建系统改进等一系列组合拳,团队估计在生产环境中获得了约20%到30%的RPS提升。在PyPerformance基准套件中,部分测试(如Richards)显示有接近4倍的加速。

未来方向

  1. 代码上游化:继续将稳定的优化贡献回CPython上游,减少版本升级的维护成本。
  2. 推进实验项目:进一步完善JIT编译器、静态Python和Pyro运行时,探索更大的性能潜力。
  3. 优化基准测试表现:调整JIT编译策略(如避免对只运行一次的函数进行JIT),以提升在标准基准测试中的表现。

Instagram的Cinder项目已在GitHub开源,他们的工作为大规模Python应用性能优化提供了宝贵的实践经验和方向。

004:高性能、高精度的CPU+GPU+内存分析器 🚀

在本节课中,我们将学习一个名为Scalene的全新Python性能分析器。它与其他分析器截然不同,能够以极低的运行时开销,提供前所未有的详细性能分析,包括CPU、GPU和内存使用情况。我们将了解它的核心功能、使用方法以及背后的技术原理。

概述 📋

我是Emery Berger,马萨诸塞大学的计算机科学教授。今天讨论的Scalene分析器,旨在解决现有Python分析器在性能开销、分析粒度、多线程支持以及综合性能洞察方面的不足。它不仅能告诉你代码哪里慢,还能告诉你为什么慢。

现有分析器的性能开销 ⚖️

分析器的一个关键特性是运行时开销要小。如果程序本身已经运行缓慢,分析器不应使其变得更慢。

为了衡量这一点,我们使用Pi Performance套件的基准测试。Y轴表示标准化执行时间,即运行分析器的时间除以原始程序运行时间。理想情况是1.0倍,表示没有开销。

以下是测试结果分类:

  • 低开销(绿色):三个分析器的开销最高不超过1.5倍,表现良好。
  • 中等开销(黄色):包括内置的cProfile,减速范围从2倍到近7倍。
  • 高开销(红色):一些分析器的开销高达近40倍。想象一下,分析一个原本只需1分钟的程序现在需要40分钟,这是不可接受的。
  • 极高开销:一个名为memory_profiler的内存分析器,开销高达近300倍。

虽然内存分析可能更耗费资源,但Scalene证明,高效的内存分析是可能的。

Scalene的定位与核心优势 ✨

Scalene在性能开销方面表现优异。在上述基准测试中,其完整模式(分析CPU、内存和GPU)仅比原始程序慢20%。它还有一些选项可以进一步降低开销。

分析粒度:函数级 vs. 行级

分析器通常分为两类:

  • 函数级分析:信息在整个函数上汇总。这对于有许多小函数的代码有效,但对于长函数或与NumPy等库交互的代码帮助有限。
  • 行级分析:为每一行代码报告信息,在分析大型函数时非常有用,但在大型程序中可能过于精细。

Scalene采用了两者兼顾的方法,同时进行函数级和行级分析

易用性与兼容性

  • 无需修改代码:与许多分析器一样,Scalene可以直接分析未修改的代码。它也支持@profile装饰器,用于在定位问题后聚焦于特定函数。
  • 多线程与多进程支持:许多分析器对Python线程支持有限。Scalene是唯一能够正确分析使用multiprocessing库代码的分析器。

超越其他分析器的独特功能

除了一个内存分析器外,其他分析器都无法执行以下操作,而Scalene可以:

  • 将Python时间与C/本地时间分开。
  • 识别系统调用(I/O)时间。
  • 进行高效的内存分析。
  • 进行GPU分析。
  • 展示内存使用趋势。
  • 报告复制量(一个揭示不必要数据拷贝的新指标)。
  • 自动检测内存泄漏。

最重要的是,Scalene在提供这些功能的同时,保持了极低的开销。

使用Scalene 🛠️

使用Scalene非常简单。基本命令是将python3替换为scalene

scalene your_script.py

以下是一些有用的选项:

  • --reduced-profile:只报告执行时间或内存分配占总量的比例超过1%的代码行。
  • --outfile <file>:将输出写入文件。
  • --html:生成包含分析结果的HTML网页。
  • --cpu-only:禁用内存分析(GPU分析仍会进行,如果可用)。此模式开销极低。

Scalene的输出分为两部分:

  1. 行级分析:顶部显示每行代码的详细性能数据。
  2. 函数级分析:底部汇总每个函数的性能信息,并按内存消耗降序排列。

Scalene实战:识别不必要的数据拷贝 🎯

上一节我们介绍了Scalene的基本功能,本节我们通过一个具体例子来看看它如何帮助我们发现隐藏的性能问题。

考虑以下使用NumPy的代码:

import numpy as np
def main():
    n = 10000
    X = np.array(np.random.uniform(0, 1, (n, n))) # 问题行
    Y = X * X
    return Y.sum()

使用传统分析器(如cProfile或line_profiler)可能只会告诉我们main函数或某一行很慢,但无法解释原因。

Scalene的分析报告则提供了更深入的洞察(此处省略函数汇总和部分列):

  • CPU时间分解:显示大部分时间花在“本地”代码(如NumPy的C库)上,这通常看起来是好事。
  • 内存分析:显示该行在“本地”代码中分配了大量内存。
  • 内存趋势图(Sparkline):显示锯齿形模式(分配后释放),暗示了临时内存分配。
  • 复制量:报告了很高的数值。

问题诊断np.random.uniform已经返回一个NumPy数组,而np.array默认会复制其输入。因此,这里的np.array调用是完全多余且昂贵的拷贝

优化方案:移除多余的np.array调用。

def main():
    n = 10000
    X = np.random.uniform(0, 1, (n, n)) # 优化后
    Y = X * X
    return Y.sum()

优化效果:Scalene报告显示:

  • CPU时间下降。
  • 内存趋势图中的锯齿形模式消失。
  • 复制量降为零。
    实际测试验证,峰值内存使用从约1.6GB降至约900MB,总执行时间减少了15%。

Scalene的技术原理 🔬

上一节我们看到了Scalene的强大之处,本节我们来了解一下它实现高精度、低开销分析背后的关键技术挑战和解决方案。

精确的时间归因:处理C代码中的信号

Scalene使用信号进行采样分析。定时器中断时,它会检查正在执行的代码行。然而,Python在运行C扩展代码(如NumPy)时,会延迟信号的传递,直到控制权返回给Python解释器。这会导致采样分析器严重低估在C代码中花费的时间。

Scalene的解决方案:使用一种算法来推断在C代码中花费的时间。

  1. 它使用一个“虚拟时钟”(进程实际在CPU上执行的时间)。

  2. 比较连续信号之间的虚拟时钟时间和真实墙钟时间。

  3. 通过公式计算各部分时间:

    总时间 = Python时间 + C时间 + 系统时间

    C时间 ≈ (墙钟时间间隔 - 虚拟时钟时间间隔)

    系统时间 ≈ 墙钟时间间隔 - (Python时间 + C时间)

通过这种方法,Scalene能准确地将时间分解为Python、本地(C)和系统(I/O)时间,即使在C代码长时间运行时也是如此。

GPU分析

如果系统有NVIDIA GPU,Scalene会自动进行GPU分析。它采用与CPU分析类似的基于定时器的采样方法,定期检查GPU的利用率,并将其归因于当前正在执行的Python代码行。这在分析使用PyTorch、TensorFlow等框架的代码时非常有用。

在Jupyter Notebook中使用Scalene:

%load_ext scalene
%scrun your_code_cell

自动内存泄漏检测

跟踪每一个内存分配和释放开销巨大。Scalene通过采样来实现高效的泄漏检测。

概念模型:将每次内存分配视为抛硬币得到“正面”,每次释放视为“反面”。没有泄漏的代码行,“正面”和“反面”的数量最终应平衡。

实现方法

  1. Scalene随机采样一部分内存分配,记录分配地址和对应的代码行。
  2. 当发生内存释放时,检查被释放的对象是否在采样记录中。
  3. 随着程序运行,统计每条代码行“未匹配的分配”(即分配后未被释放的采样对象)数量。
  4. 如果某行代码的未匹配分配数量持续显著偏高,Scalene就以高概率报告该行存在内存泄漏,并估算泄漏速率(MB/s)。

这种方法让开发者能够快速聚焦于真正严重的内存泄漏问题。

总结 🎉

在本节课中,我们一起学习了Scalene这个高性能的Python分析器。它远非“又一个Python分析器”,而是提供了独特的价值:

  • 全面分析:以低开销(~20%)同时提供CPU(分解为Python、本地、系统时间)、GPU内存(含趋势和复制量)分析。
  • 高精度:通过创新算法,即使在C扩展代码中也能准确归因时间。
  • 智能检测:自动识别内存泄漏和不必要的数据拷贝。
  • 强大兼容:支持多线程、多进程代码的分析。
  • 易于使用:简单的命令行接口,支持输出到HTML,兼容Jupyter Notebook。

希望你能使用Scalene来更有效地识别和修复代码中的性能瓶颈。我们期待您的反馈和成功案例!

005:从零到生产就绪的最佳实践流程 🚀

概述

在本教程中,我们将学习如何将一个Python应用程序打包为可用于生产的Docker镜像。我们将遵循一个迭代的、有优先级的最佳实践流程,确保在每个步骤都能获得可用的成果,并最终得到一个安全、高效、可维护的镜像。


第1章:为什么Docker打包如此复杂?🤔

上一节我们介绍了本教程的目标,本节中我们来看看Docker打包为何充满挑战。

Docker打包之所以复杂,主要有两个原因。

第一个原因是历史和技术堆栈的复杂性。 Docker建立在可以追溯到50年前的原始Unix设计技术之上,例如信号。在接下来的几十年里,网络、Python等所有技术都在此基础上构建。为了让打包正常工作,你需要处理所有这些技术在一个地方的交汇点,包括它们的设计决策和潜在的设计错误。

第二个原因是它处于组织内部多个流程的交汇点。 编写软件时,你需要编写代码、进行测试、打包、部署、在生产中运行、升级以及处理错误报告。打包过程与所有这些不同的流程相互作用,这构成了另一个复杂性来源。打包并非孤立事件,它几乎与你对软件所做的所有事情都相关。

因此,我们无法在短时间内覆盖所有细节。本教程旨在帮助你理解大局,并提供一个可以逐步实施的、有优先级的流程。


第2章:迭代式打包流程概述 🔄

上一节我们探讨了复杂性,本节中我们来看看应对这种复杂性的方法。

由于时间有限且可能被打断,我们不应期望一次性完成所有打包工作。相反,我们采用一种迭代的、有优先级的“装饰化”流程。

这个过程分为多个逻辑步骤,每一步都建立在前一步的基础上,并改善打包结果。如果在任何步骤被打断,你都会停留在一个相对完善的节点上。

以下是本教程将遵循的通用顺序。虽然你可能需要根据具体情况调整,但此顺序为大多数人提供了一个良好的起点:

  1. 让应用程序运行起来。
  2. 确保安全性。
  3. 自动化构建。
  4. 提升可操作性与可调试性。
  5. 确保构建的可复现性。
  6. 优化构建速度和镜像大小。

我们将首先处理最重要的事项。例如,安全性通常比拥有一个极小的镜像更为关键。


第3章:步骤1 - 让应用程序运行起来 🏃‍♂️

上一节我们介绍了整体流程,本节中我们来看看第一步:让应用程序在Docker中运行。

如果应用程序无法运行,其他所有优化都毫无意义。因此,第一步是专注于让应用程序在容器内正常工作。

在这个过程中,你需要考虑几个关键配置问题:

  • 配置管理:Docker鼓励使用环境变量,但在某些环境中(如Kubernetes),挂载配置文件可能是更好的选择。
  • 网络端口:确定哪些端口需要公开,哪些应保持私有。
  • 依赖安装:确保所有必要的依赖包和软件都已正确安装。

以下是一个能让简单应用程序运行的最基础Dockerfile示例。这并非最佳实践,但它是我们旅程的起点。

# 示例:最基础的Dockerfile
FROM python:3.9
COPY . /app
WORKDIR /app
RUN pip install -r requirements.txt
CMD ["python", "app.py"]

第4章:步骤2 - 确保镜像安全性 🛡️

上一节我们让应用跑了起来,本节中我们来看看如何加固它的安全性。

在将镜像部署到任何地方或推送到公共注册表之前,确保其安全性至关重要。例如,你需要防止构建密钥等秘密信息泄露到镜像中。

安全性不仅涉及配置,还要求建立持续的安全更新流程。因为Docker镜像是不可变的,当基础镜像或依赖库(如OpenSSL)出现安全漏洞时,你需要重建并重新部署新镜像。

以下是众多安全最佳实践中的一个关键示例:避免以root用户身份运行容器。虽然容器提供了一定隔离,但以root身份运行仍会扩大攻击面,让攻击者更容易逃逸或控制宿主机。

# 示例:创建非root用户运行应用
FROM python:3.9-slim-buster

# 创建名为‘appuser’的用户和组
RUN groupadd -r appuser && useradd -r -g appuser appuser

# 后续命令(包括pip install和容器启动)都将以appuser身份运行
USER appuser

COPY --chown=appuser:appuser . /app
WORKDIR /app
RUN pip install --user -r requirements.txt
CMD ["python", "app.py"]

第5章:步骤3 - 自动化构建流程 🤖

上一节我们加固了安全,本节中我们来看看如何将构建过程自动化。

一旦拥有了可工作的安全镜像,下一步就是将其集成到持续集成(CI)系统中。自动化构建可以避免手动操作的错误,并方便团队协作。

最简单的自动化是在每次代码提交到主分支时触发构建、测试并将镜像推送到注册表。然而,你还需要考虑更复杂的场景,例如如何处理功能分支的构建,以及如何避免不同分支的镜像相互覆盖。

以下是一个处理功能分支镜像命名的示例。通过根据Git分支名称来标记镜像,可以清晰区分镜像来源,并防止功能分支的镜像覆盖生产镜像。

# 示例:在构建脚本中根据Git分支命名镜像
#!/bin/bash
# 获取当前Git分支名,并替换斜杠为连字符(Docker标签不允许斜杠)
BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD | sed 's/[\/]/_/g')
IMAGE_TAG="myapp:${BRANCH_NAME}"

docker build -t $IMAGE_TAG .
# ... 后续推送等操作

第6章:步骤4 - 提升可操作性与可调试性 🔍

上一节我们实现了自动化,本节中我们来看看如何让镜像在生产中更易于运维和调试。

随着镜像被自动构建并部署到生产环境,你需要确保它们能够优雅地启动和关闭,并且在出现问题时易于诊断。

以下是两个提升可调试性的实用技巧:

1. 启用Python故障处理器(Faulthandler):当Python扩展模块(C/C++编写)发生段错误时,默认只会静默崩溃。启用故障处理器后,你会得到一个清晰的Python堆栈跟踪,指明问题源头。

# 示例:在Dockerfile中启用Python故障处理器
ENV PYTHONFAULTHANDLER=1

2. 预编译Python字节码(.pyc文件):Python启动时会编译.py文件为.pyc字节码。在不可变的Docker镜像中预编译这些文件,可以加速应用的启动过程。

# 示例:在构建时预编译所有Python字节码
RUN python -m compileall .

第7章:步骤5 - 确保构建的可复现性 🔒

上一节我们优化了运维体验,本节中我们来看看如何确保每次构建都能得到一致的镜像。

可复现的构建意味着,无论何时何地重建镜像,只要源代码和依赖声明不变,你就能得到完全相同的产物。这对于调试、回滚和确保生产环境稳定性至关重要。

实现可复现性需要关注两个方面:

  1. 稳定的基础镜像:使用提供长期支持(LTS)并保证向后兼容性的基础镜像,例如官方的python:3.9-slim-buster(基于Debian Buster)。
  2. 固定的依赖版本:使用如pip-toolspoetryconda等工具锁定所有Python依赖的确切版本。

然而,固定依赖版本会带来“依赖漂移”问题——长期不更新会导致升级时面临大量不兼容变更。因此,你需要建立一个组织流程,例如制定计划,每季度定期评估和升级依赖项,从而在短期稳定性和长期可维护性之间取得平衡。


第8章:步骤6 - 优化构建速度与镜像大小 ⚡

上一节我们确保了构建的一致性,本节中我们来看看流程的最后一步:性能优化。

缓慢的构建(例如30分钟)会拖慢开发流程。庞大的镜像则会导致下载缓慢,增加带宽成本。因此,在完成前述所有重要步骤后,可以专注于优化。

一个常见的误区是使用Alpine Linux作为基础镜像来减小体积。但在Python生态中,这通常会导致构建时间急剧增加,因为许多包没有为Alpine预编译的轮子(wheel),需要从源码编译。

例如,安装pandas

  • 在Debian系镜像上:下载预编译轮子,约需30秒
  • 在Alpine镜像上:从源码编译,约需1500秒慢50倍)。

因此,对于Python应用,通常建议使用基于Debian的slim版本(如python:3.9-slim-buster)作为速度和体积的较好折衷。未来,随着PEP 656的普及,Alpine的体验可能会改善。


总结

在本教程中,我们一起学习了将Python应用Docker化的一个迭代式、有优先级的六步流程:

  1. 让应用运行:确保基础功能在容器内正常工作。
  2. 确保安全:实施如使用非root用户等安全最佳实践,并建立安全更新流程。
  3. 自动化构建:集成CI/CD,实现自动构建和推送。
  4. 提升可运维性:通过启用故障处理器、预编译字节码等方式,让生产环境中的镜像更易于调试和运行。
  5. 确保可复现性:使用稳定基础镜像和锁定依赖版本,保证构建结果一致,并建立定期依赖更新流程。
  6. 优化性能:最后考虑优化构建速度和最终镜像大小。

请记住,Docker化不仅仅是编写配置文件,它还是一个涉及持续流程(如安全更新、依赖管理)并与团队开发流程(如分支策略、测试)深度互动的系统工程。希望这份路线图能帮助你更有信心地开启生产就绪的Docker之旅。


(注:本教程基于演讲内容整理,更多详细的最佳实践和深入指南,请参考演讲者网站 pythonspeed.com 上的相关文章和产品。)

006:2021年Python打包新标准

在本节课中,我们将学习Python打包的最新标准,特别是如何使用pyproject.toml文件来编写易于构建的项目。我们将了解如何维护明确、可预测且可读的构建信息,这些信息可以被人类和机器更好地理解。

概述:为什么需要新的打包标准? 🎯

过去,Python打包主要依赖于setup.pysetup.cfg文件,这导致构建过程不够明确,且严重依赖于特定的工具链。新的pyproject.toml标准旨在解决这些问题,通过分离构建前端、构建后端和集成前端,为项目提供更灵活、更可靠的构建方式。

核心概念:现代Python打包架构 🏗️

上一节我们介绍了新标准的必要性,本节中我们来看看现代Python打包的核心架构。它将打包过程清晰地分为三个独立的类别。

以下是三种主要的工具类别:

  1. 构建后端:负责将源代码转换为分发格式(如wheel文件)。项目作者选择并记录在pyproject.toml中。

    • 公式/代码示例build-backend = “setuptools.build_meta”
  2. 构建前端:处理用户交互(如命令行界面、显示进度和错误)。它负责为构建后端准备执行环境。

    • 公式/代码示例pipbuild
  3. 集成前端:负责安装已构建的分发包,并解析和安装其依赖项。

    • 公式/代码示例pipconda

这种分离使得项目作者、构建者和安装者可以独立选择各自偏好的工具,提高了生态系统的灵活性。

历史回顾:从distutils到pip 📜

上一节我们了解了现代打包架构,本节中我们来看看Python打包是如何一步步发展到今天的。理解历史有助于我们明白为什么新标准是必要的。

Python打包的演变大致经历了以下几个阶段:

  1. distutils:Python 1.6引入的标准库模块。它提供了最初的打包功能,但功能有限,且已被弃用。
  2. setuptools:作为distutils的扩展出现,引入了easy_install作为集成前端,并增加了依赖管理等功能。
  3. pip:取代了easy_install,成为主流的集成前端。它简化了安装过程,并能从版本控制系统安装包。

长期以来,pipsetuptools的组合掩盖了构建前端、构建后端和集成前端的界限,导致了一些混乱和“引导问题”。

关键问题:为什么需要pyproject.toml? ❓

上一节我们回顾了历史,本节中我们来看看一个具体的、由旧标准导致的问题,以及pyproject.toml如何解决它。

一个常见的问题是“缺少wheel包”的错误。当使用pip从源代码安装一个没有pyproject.toml的旧项目时,可能会遇到类似以下的错误:

error: invalid command ‘bdist_wheel’

这个错误的根源在于,pip(作为构建前端)试图调用setuptools(作为构建后端)来构建一个wheel文件,但setuptools本身并不知道如何构建wheel,它需要一个额外的wheel包。旧的标准无法明确声明这个构建时依赖。

解决方案:在项目的pyproject.toml文件中明确声明构建后端及其依赖。

[build-system]
requires = [“setuptools”, “wheel”]
build-backend = “setuptools.build_meta”

这样,任何构建工具在开始构建前,都会确保这些依赖已安装,从而避免了令人困惑的错误。

实践指南:如何创建和使用pyproject.toml 🛠️

上一节我们了解了pyproject.toml的重要性,本节中我们来看看如何实际为你的项目创建和使用它。

使用pyproject.toml能带来诸多好处:构建环境明确、工具选择灵活、项目结构更清晰。

以下是现代化你的项目打包的步骤:

  1. 创建基本的pyproject.toml文件:即使你仍使用旧工具,添加此文件也能避免“wheel错误”。

    [build-system]
    requires = [“setuptools”, “wheel”]
    build-backend = “setuptools.build_meta”
    
  2. 停止将setup.py用作脚本:它的脚本功能正在被淘汰。将构建逻辑交给外部的构建工具(如build)。

  3. 将元数据迁移到setup.cfgsetuptools目前主要通过setup.cfg读取项目元数据(如名称、版本、依赖)。尝试将setup.py中的所有配置信息移入setup.cfg

  4. 最终目标:移除setup.py:当所有配置都移至setup.cfgpyproject.toml后,你可以完全删除setup.py文件,实现完全声明式的配置。

总结 📝

本节课中我们一起学习了2021年Python打包的新标准。我们了解了现代打包架构将过程分为构建后端、构建前端和集成前端。我们回顾了打包工具的历史演变,并分析了旧标准导致的典型问题。最后,我们掌握了如何通过创建和配置pyproject.toml文件来现代化项目构建,使其更明确、可靠和易于维护。

记住,打包是一个不断发展的领域,持续学习最新的实践和工具对你的项目大有裨益。

007:利用异步特性进行无暂停调试 🚀

在本教程中,我们将学习如何利用Python异步编程的特性,在不暂停应用程序运行的情况下,检查和修改其内部状态。这对于调试具有用户界面、网络连接或与物理世界紧密交互的程序(如机器人)尤其有用。

概述:为何需要无暂停调试? 🤔

上一节我们介绍了异步编程的基本概念。本节中,我们来看看在调试时暂停程序可能带来的问题。当程序与用户、网络服务或物理世界(如机器人)实时交互时,暂停程序以检查状态会中断正常操作,甚至可能引发错误。因此,我们需要一种能够实时观察和干预程序状态的方法。

传统调试方法的局限性 🛑

在深入新方法之前,我们先回顾一下几种传统的状态检查方法及其局限性。

以下是几种常见的状态检查方法:

  1. 控制台/REPL:使用print语句或交互式环境。优点是简单直接,但需要访问终端,且会干扰控制台的其他输出(如日志)。
  2. 图形用户界面:在GUI中嵌入开发者视图。这能提供丰富的显示,但实现复杂,且并非所有应用都有GUI。
  3. 日志记录:将状态信息输出到日志文件。适合事后分析,但无法进行实时交互。

这些方法共同的缺点是:它们通常需要程序做出特殊安排或暂停执行,并且难以进行远程访问和丰富的交互式可视化。

核心方案:利用异步与协作式多任务 🧠

Python的异步编程(asyncio)采用协作式多任务处理。这意味着在await表达式之间,代码执行是连续的、原子性的。我们可以利用这一点,在await的间隙安全地检查程序状态,而不会看到不一致的中间状态。

核心概念公式

程序状态在 `await` 语句之间是“冻结”且一致的。
调试工具可以在这些安全点取样,获得程序状态的可靠快照。

实践工具介绍:Pura 🎨

为了实践无暂停调试,我们将介绍一个名为Pura的工具。它允许开发者为程序组件创建可远程访问的、交互式的网页可视化视图,而无需暂停程序运行。

Pura的设计原则是:

  • 零开销:当没有观察者连接时,可视化代码不产生任何运行时开销。
  • 简单易用:开发者只需在需要可视化的类中实现一个draw方法。
  • 远程与图形化:通过网页浏览器即可远程访问丰富的图形化界面。
  • 解耦:可视化逻辑与程序的核心业务逻辑分离。

如何使用Pura创建可视化?

上一节我们了解了Pura的理念,本节中我们来看看如何实际使用它。假设我们有一个控制机器人腿部的状态机类。

以下是创建可视化步骤的示例代码:

import pura

class LegState(pura.Mixin): # 继承Pura的Mixin类
    def __init__(self):
        self.servo_on = False
        self.target_position = 0.0
        self.current_position = 0.0

    async def update(self):
        # 这里是状态机的主要逻辑
        # ...
        # 在合适的await点,Pura可以安全地检查`self`的状态
        await asyncio.sleep(0.03)

    def draw(self, g):
        """ Pura会调用此方法来绘制可视化图形 """
        # 设置背景
        g.background(40)

        # 绘制目标位置(白色虚线)
        g.stroke(255)
        g.stroke_weight(2)
        g.line(self.target_position, 20, self.target_position, 80)

        # 绘制当前位置(蓝色实线)
        g.stroke(0, 150, 255)
        g.stroke_weight(4)
        line_x = self.current_position
        g.line(line_x, 20, line_x, 80)

        # 显示文本状态
        g.fill(255)
        status = "SERVO ON" if self.servo_on else "SERVO OFF"
        g.text(f"状态: {status}", 10, 95)

代码解释

  1. 类继承自pura.Mixin
  2. draw(self, g)方法是与Pura的契约。参数g是一个图形上下文对象。
  3. draw方法内,我们使用类似Processing库的API(如stroke, line, text)来定义可视化内容。
  4. 可视化逻辑紧邻着它所展示的状态变量,保持了代码的本地化可读性

Pura的优势与工作流程 🔧

使用Pura后,开发者可以:

  1. 在网页浏览器中连接到运行中的程序。
  2. 实时查看状态机的图形化表示(如位置、开关状态)。
  3. 甚至可以通过可视化界面进行交互(如手动拖动目标位置)。
  4. 多个观察者可以同时连接,视图状态会同步。

其底层架构利用了异步WebSocket服务器,draw方法生成的绘图指令会被发送到所有连接的客户端浏览器执行。

总结与展望 📚

本节课中我们一起学习了如何利用Python异步编程的“协作式多任务”特性,实现程序的无暂停调试。我们介绍了传统调试方法的局限,并深入探讨了Pura这一工具,它通过提供零开销可远程访问交互式可视化,极大地提升了开发、调试和理解复杂异步应用(特别是机器人、IoT设备)的效率。

未来,这种思路可以扩展到更广泛的领域,例如:

  • 实时变量监视器:像调试器一样展示变量,但无需暂停。
  • 运行时堆栈探查:观察长时间运行任务的内部状态。
  • 性能剖析可视化:实时展示程序性能热点。

希望本教程能启发你探索异步编程的更多可能性,并尝试将无暂停调试的理念融入自己的项目中。

008:使用声明式配置实现可维护与可重现性 🛠️

在本教程中,我们将学习如何通过声明式配置来构建可维护且可重现的代码。我们将探讨如何将配置与代码逻辑分离,如何验证配置,以及如何管理配置的演变,以确保旧实验能够适应新的代码库。


概述

在数据处理和机器学习项目中,我们经常需要运行大量实验。每个实验可能涉及不同的数据集、参数和功能。随着时间推移,代码库和实验需求会不断演变,这带来了两个核心挑战:可维护性(清晰管理众多实验的配置)和可重现性(确保旧实验能用最新代码重新运行)。本教程将介绍如何利用声明式配置来解决这些挑战。


分离配置与代码

上一节我们概述了核心挑战,本节中我们来看看实现可维护性的第一个关键步骤:将配置与代码逻辑分离。

将配置(如文件路径、参数阈值)硬编码在代码中会使代码难以维护和复用。声明式配置意味着配置只描述“是什么”(数据),而不包含“如何做”(逻辑)。这迫使配置保持简单,所有复杂逻辑都留在应用程序代码中。

我们需要一种声明式的输入格式来承载配置,并在代码中表示它。这通常涉及三个部分:

  1. 输入格式:如命令行参数、环境变量或配置文件(YAML/JSON)。
  2. 代码表示:将输入数据转换为代码中的结构化对象(如类实例)。
  3. 反序列化:将输入格式(如YAML文件)转换为代码表示的工具。

以下是几种常见的配置输入方式比较:

  • 命令行参数:适合少量参数,可使用 argparse 或更类型安全的 typer 库。
  • 环境变量:通过 os.environ 访问,适合简单配置。
  • 配置文件(如YAML):适合复杂、结构化的配置,需要使用第三方库(如 PyYAML)加载。

在代码中表示配置

上一节我们介绍了配置的输入格式,本节中我们来看看如何在Python代码中安全、高效地表示这些配置。

直接将YAML加载为Python字典(dict)虽然简单,但容易因拼写错误键名而导致运行时错误。更好的方法是将配置定义为带有类型注解的类。这允许我们进行静态类型检查,提前发现错误。

我们可以使用 dataclasses(Python内置)或第三方库如 attrs 来自动生成类的特殊方法(如 __init__)。然后,使用如 cattrs 这样的库,将原始的字典数据“结构化”为我们定义好的类对象。

核心概念公式
原始配置数据 (YAML/JSON) -> Python字典 -> 结构化转换器 (如cattrs) -> 类型化的配置类对象


实践:一个配置化实验的例子

让我们通过一个具体的例子来实践上述概念。假设我们有一个实验:加载数据集,检测异常值,然后绘图。

1. 定义配置模式类
我们首先定义一个类,来描述实验所需的所有配置参数及其类型。

from enum import Enum
from typing import Optional
import attr

# 定义数据集的枚举
class DatasetType(Enum):
    LINEAR = "linear"
    IRIS = "iris"

# 定义散点图配置模式
@attr.define
class ScatterPlotConfig:
    x_axis: str
    y_axis: str
    z_axis: Optional[str] = None  # 可选参数,用于3D绘图

# 定义热图配置模式
@attr.define
class HeatmapConfig:
    x_axis: str
    y_axis: str

# 主配置模式,整合所有设置
@attr.define
class ExperimentConfig:
    dataset: DatasetType
    outlier_factor: int
    plot_config: ScatterPlotConfig  # 后续会扩展为联合类型

2. 创建声明式配置文件 (如 config.yaml)

dataset: "linear"
outlier_factor: 10
plot_config:
  x_axis: "pull-ups"
  y_axis: "jumps"

3. 加载并结构化配置
在代码中,我们加载YAML文件,并使用 cattrs 将其转换为 ExperimentConfig 对象。

import yaml
import cattr

# 加载YAML文件
with open('config.yaml', 'r') as f:
    config_dict = yaml.safe_load(f)

# 将字典结构化为配置对象
config = cattr.structure(config_dict, ExperimentConfig)

# 在代码中使用配置对象
print(f"使用数据集: {config.dataset}")
print(f"绘图X轴: {config.plot_config.x_axis}")

通过这种方式,配置与代码完全分离。要运行新实验,只需创建新的YAML文件,而无需修改代码逻辑。


验证配置与代码

上一节我们实现了配置的加载,本节中我们来看看如何确保配置和代码的正确性,避免运行时错误。

使用类型化的配置类带来了一个巨大优势:静态类型检查。我们可以使用如 mypy 这样的工具,在运行代码前检查配置对象的使用是否正确。

例如,如果我们在代码中错误地引用了 config.plot_config.wrong_keymypy 会提前报错,指出 wrong_key 不是 ScatterPlotConfig 的有效属性。这比程序运行到一半因 KeyError 崩溃要可靠得多。

验证流程

  1. 使用 cattrs 在加载时验证配置数据是否匹配类结构(如类型错误会抛出异常)。
  2. 使用 mypy 检查代码中所有对配置对象的访问是否有效。

处理复杂的配置结构

随着功能增加,配置可能变得复杂。例如,我们可能支持多种绘图类型(散点图、热图)。

我们可以使用联合类型来定义这种“多选一”的配置结构。然后,在代码中根据配置的类型决定执行哪段逻辑。

1. 扩展配置模式

from typing import Union

# 更新主配置模式中的plot_config字段
@attr.define
class ExperimentConfig:
    dataset: DatasetType
    outlier_factor: int
    plot_config: Union[ScatterPlotConfig, HeatmapConfig]  # 联合类型

2. 在代码中处理不同配置

# 使用配置对象
if isinstance(config.plot_config, ScatterPlotConfig):
    # 执行散点图逻辑
    if config.plot_config.z_axis:
        create_3d_scatter_plot(...)
    else:
        create_2d_scatter_plot(...)
elif isinstance(config.plot_config, HeatmapConfig):
    # 执行热图逻辑
    create_heatmap(...)

这样,我们就能在一个统一的配置框架下,灵活支持多种实验功能。


管理配置的演变

上一节我们处理了复杂的配置结构,本节中我们来看看最后一个挑战:如何让旧的实验配置适应新的代码。

代码库在演进,配置模式也可能改变(例如,添加新字段、修改字段名)。我们希望旧实验的配置文件仍能被新代码运行。这需要通过配置迁移(演变)来实现。

一个简单的方法是为配置文件添加版本号,并编写迁移函数。

1. 版本化配置文件

version: 1  # 配置文件版本
dataset: "linear"
outlier_factor: 10
axes:
  x: "pull-ups"
  y: "jumps"

2. 编写迁移逻辑
当代码升级,配置模式变为 version: 2(例如,axes 字段改名为 plot_config),我们可以在加载配置后执行迁移。

def migrate_config(config_dict):
    if config_dict.get('version', 1) == 1:
        # 将v1格式迁移到v2格式
        config_dict['plot_config'] = {
            'x_axis': config_dict.pop('axes')['x'],
            'y_axis': config_dict.pop('axes')['y']
        }
        config_dict['version'] = 2
    return config_dict

# 加载配置后先迁移
config_dict = yaml.safe_load(open('old_config_v1.yaml'))
config_dict = migrate_config(config_dict)
# 然后再结构化为当前版本的配置对象
config = cattr.structure(config_dict, ExperimentConfig)

通过这种方式,我们实现了向后兼容,确保了旧实验的可重现性。


总结

在本教程中,我们一起学习了如何利用声明式配置构建可维护和可重现的代码系统。

  • 分离关注点:通过声明式YAML文件将配置与代码逻辑分离,提升了可维护性。
  • 类型安全表示:使用 attrscattrs 库将配置转换为类型化的类对象,使配置结构清晰。
  • 静态验证:结合 mypy 进行类型检查,提前捕获配置使用错误,增强代码健壮性。
  • 灵活的结构:通过联合类型支持复杂的、多分支的配置。
  • 演进与兼容:通过版本化和迁移机制,管理配置模式的变更,确保旧实验能适应新代码,保障了可重现性。

这套方法不仅适用于数据科学管道,也可应用于任何需要复杂配置和长期维护的软件项目。记住,目标是让配置简单明了,让逻辑待在代码里,并通过自动化工具来保证两者之间的一致性。

009:协议——类型提示的基石 🧱

在本节课中,我们将学习Python类型系统中的协议,并理解其在静态类型检查中的核心作用。我们将通过分析内置函数max的类型注解难题,逐步探索如何利用协议和类型变量来精确描述函数的行为,从而掌握这一强大的类型工具。


概述

Python的类型提示系统旨在为动态语言提供静态检查的能力。然而,像max这样灵活的内置函数,其参数可以是数字、字符串、列表等多种类型,如何为其编写精确的类型提示曾是一个挑战。本节课将带你了解解决这一挑战的关键——协议,并展示如何结合类型变量来构建强大且准确的类型注解。


静态类型与动态类型

理解协议之前,我们需要区分两个核心概念。

  • 静态类型:类型检查发生在程序运行之前(例如,通过编译器、代码检查器或mypy等工具)。这有助于在早期发现类型错误。
  • 动态类型:类型检查发生在程序运行时。这提供了更大的灵活性,但也意味着错误可能在后期才被发现。

另一个重要的维度是鸭子类型。其核心理念是:“不要检查它是不是一只鸭子,而是检查它是否会像鸭子一样叫或走。”这意味着我们只关心对象是否具备我们所需的方法或行为,而不关心其具体的类名或继承关系。


结构类型与名义类型

根据类型检查的依据,我们可以将类型系统分为两类:

  • 结构类型:类型的兼容性由对象实际提供的结构(即拥有的方法和属性)决定。类名和继承关系不重要。
  • 名义类型:类型的兼容性由显式声明的类型名称(如类名、接口名)决定。这是Java等语言采用的方式。

Python的协议特性,正是支持静态鸭子类型(即对结构类型进行静态检查)的关键。


挑战:为 max 函数添加类型提示

让我们从一个具体的例子开始。Python内置的max函数功能强大,但其灵活性给类型提示带来了困难。考虑以下简化场景:一个只接受两个参数的max函数。

# 我们希望能为这个函数添加准确的类型提示
def my_max(a, b):
    if a >= b:
        return a
    else:
        return b

我们希望my_max能正确处理floatintFraction(分数)等不同类型。最初的尝试可能是将参数和返回值都标注为float

def my_max(a: float, b: float) -> float:
    if a >= b:
        return a
    else:
        return b

问题:当我们传入两个Fraction对象时,mypy会报错,因为Fractionfloat类型不兼容。我们需要一个更通用的解决方案。


尝试与失败:数字ABC与联合类型

以下是两种不成功的尝试,但它们能帮助我们理解问题所在。

尝试一:使用 numbers.Number ABC

Python标准库的numbers模块提供了抽象基类(ABC),如Number

from numbers import Number

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/049b8aef205d3b9adf34c6d01a43f325_7.png)

def my_max(a: Number, b: Number) -> Number:
    if a >= b:  # mypy 会在这里报错!
        return a
    else:
        return b

失败原因Number ABC没有定义具体的比较方法(如__ge__)。静态类型检查器mypy无法知道Number类型的对象支持>=操作,因此会报错。numbers ABC主要用于运行时检查,对静态类型检查帮助有限。

尝试二:使用联合类型

我们可以定义一个包含所有可能数值类型的联合类型。

from typing import Union
from fractions import Fraction
from decimal import Decimal

Numeric = Union[float, Decimal, Fraction]

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/049b8aef205d3b9adf34c6d01a43f325_11.png)

def my_max(a: Numeric, b: Numeric) -> Numeric:
    if a >= b:
        return a
    else:
        return b

问题

  1. 返回类型过于宽泛:如果我传入两个float,我知道返回值是float,但类型提示说它可能是floatDecimalFraction中的任何一个。这不够精确。
  2. 无法访问特定方法:如果我传入两个Fraction,结果是一个Fraction,我想访问其.numerator属性。但mypy会阻止我,因为Numeric联合类型并不保证一定有.numerator属性。

解决方案一:受限类型变量

对于数值类型,一个有效的解决方案是使用受限类型变量

from typing import TypeVar
from fractions import Fraction
from decimal import Decimal

# 定义一个类型变量T,但它只能是float, Decimal, Fraction中的一种
T = TypeVar('T', float, Decimal, Fraction)

def my_max(a: T, b: T) -> T:
    if a >= b:
        return a
    else:
        return b

工作原理:当mypy分析一个具体的函数调用时(例如my_max(Fraction(1,2), Fraction(1,3))),它会将T绑定到Fraction类型。因此,它知道参数ab和返回值都是Fraction类型,从而实现了精确的类型推断。

局限性:这个方法将类型限制在了一个明确的列表里。但max函数还能处理字符串、列表、元组等。我们需要一个更通用的机制来描述“任何可比较的对象”。


关键概念:协议

协议定义了一个对象必须实现的方法集合。它是一种描述结构类型的工具。以下是定义一个简单协议的语法:

from typing import Protocol

# 定义一个协议,要求实现 __lt__ (小于) 方法
class SupportsLessThan(Protocol):
    def __lt__(self, other) -> bool:
        ...  # 省略号表示我们只关心方法签名,不关心实现

任何实现了__lt__方法的类,都自动被视为SupportsLessThan协议的子类型。在Python中,大多数排序和比较操作(如max, sorted)在内部都依赖于<运算符,因此这个协议非常有用。


尝试与失败:仅使用协议

我们尝试仅用协议来注解my_max

from typing import Protocol

class SupportsLessThan(Protocol):
    def __lt__(self, other) -> bool: ...

def my_max(a: SupportsLessThan, b: SupportsLessThan) -> SupportsLessThan:
    if b < a:  # 注意:这里我们改用 < 运算符
        return a
    else:
        return b

问题:类型丢失了。如果我传入两个字符串,结果应该是一个字符串。但根据类型提示,返回值只是SupportsLessThan类型。mypy不会允许我对结果调用字符串特有的方法,例如.upper()


最终解决方案:协议 + 有界类型变量

协议有界类型变量结合,我们得到了完美的解决方案。

from typing import Protocol, TypeVar

# 1. 定义协议
class SupportsLessThan(Protocol):
    def __lt__(self, other) -> bool: ...

# 2. 定义有界类型变量,其边界是协议
T = TypeVar('T', bound=SupportsLessThan)

# 3. 使用有界类型变量进行注解
def my_max(a: T, b: T) -> T:
    if b < a:
        return a
    else:
        return b

工作原理

  1. T是一个类型变量,但它不是被限制在几个具体类型里,而是被绑定SupportsLessThan协议。
  2. 这意味着T可以是SupportsLessThan任何子类型
  3. mypy分析my_max('hello', 'world')时:
    • 它看到第一个参数是str
    • 它检查str是否实现了__lt__方法(是的,字符串可以比较)。
    • 因此,strSupportsLessThan的子类型,符合T的边界。
    • mypy将本次调用中的T推断为str
    • 于是,它知道ab和返回值都是str类型。

这样,我们既保证了参数是可比较的(通过协议),又保留了参数的具体类型信息(通过有界类型变量),实现了精确而灵活的类型提示。

在实际的Python标准库类型存根中,max函数的注解正是使用了SupportsLessThan协议,并通过多个重载来处理不同的参数组合情况。


总结

本节课我们一起学习了Python类型系统的基石之一——协议

  1. 协议允许我们基于对象的结构(即拥有的方法)来定义类型,这是实现静态鸭子类型的关键。
  2. 通过分析为max函数添加类型提示的挑战,我们看到了从简单注解、联合类型到最终使用协议+有界类型变量的演进过程。
  3. 单独的协议会丢失具体类型信息,而有界类型变量TypeVar(..., bound=Protocol))能将协议与具体类型关联起来,提供既安全又精确的类型推断。

Python的类型系统是一种渐进式类型系统。我们不必强制所有代码都通过100%的类型检查,而是可以根据需要,在合适的地方使用类型提示来提升代码的可靠性和可维护性。协议这一特性,使得我们能够为Python中大量基于鸭子类型的灵活代码编写出优雅而准确的类型注解,是平衡动态语言灵活性与静态类型安全性的强大工具。

010:演讲 _ Maggie Moss _ 渐进式类型实践

在本教程中,我们将学习什么是渐进式类型,以及如何在Python项目中实践它。我们将探讨添加类型的好处、使用的工具、具体的实施策略,以及处理复杂代码模式的方法。通过本教程,你将能够开始为自己的Python代码库添加类型,并享受类型安全带来的诸多优势。

什么是渐进式类型? 🧐

上一节我们介绍了本教程的概述,本节中我们来看看渐进式类型的基本概念。

类型可以被视为描述一组具有共同操作的值。例如,int类型描述了一组支持加法和减法等操作的数字。当我们尝试对错误类型的值应用某个操作时,就会发生类型错误。

在静态类型语言(如Java、C)中,类型错误在程序运行前就会被编译器捕获。变量、参数、返回类型等都需要明确的类型注解。

在动态类型语言(如Python)中,值有类型,但变量和函数没有。类型检查发生在程序执行期间,这使得处理运行时依赖的类型变得容易,但也可能导致运行时才暴露的类型错误。

渐进式类型系统允许程序的某些部分是动态类型,而其他部分是静态类型。在Python中,只有带注解的函数才会进行类型检查。未注解的函数被假定为可以接受任何类型并返回任何类型(即Any类型)。这意味着你可以逐步为代码添加类型,在此过程中持续获得类型检查带来的好处。

为什么要在Python中添加类型? 🤔

上一节我们了解了渐进式类型的概念,本节中我们来看看为Python代码添加类型的具体好处。

假设你正在修复一个在线商店的bug,需要理解一个函数是否会返回None。如果函数体复杂,有多个返回路径,手动分析会非常耗时且容易出错。

以下是添加类型注解后的示例:

def get_products(cart: ShoppingCart) -> List[Product]:
    ...

通过运行类型检查器,你可以快速发现返回类型不兼容的错误,从而自信地进行代码更改。类型注解提供了以下核心优势:

  • 内置的最新文档:函数签名清晰地说明了输入和输出的类型。
  • 提前捕获错误:在代码运行前发现类型不匹配的问题。
  • 简化单元测试:测试可以更专注于业务逻辑,而非类型错误。
  • 增强IDE支持:获得更好的代码补全和实时错误提示。
  • 赋能开发工具:使代码修改工具(如LibCST)和安全分析工具(如Pysa)更强大。

如何开始为项目添加类型? 🚀

上一节我们探讨了添加类型的好处,本节中我们来看看如何迈出第一步。

我们的首要建议是:为你最常用的模块添加类型注解。类型覆盖具有网络效应,为核心模块添加类型能最大程度地揭示代码库中的潜在类型错误,从而以最小的投入获得最大的覆盖度。

当你首次添加类型并运行类型检查器时,可能会看到大量来自其他文件的错误。这是因为之前未注解的调用现在与新的具体类型产生了冲突。这是正常现象,表明类型检查器正在工作。

此时,你可能没有时间立即修复所有错误。Python类型系统允许你使用# type: ignore注释来暂时抑制特定行的错误。这应被视为临时解决方案,目的是为了逐步推进,而非永久忽略问题。

为了方便这一过程,Pyre提供了pyre-upgrade工具,可以自动为项目中的所有错误添加或移除# type: ignore注释。

如何防止类型质量倒退? 🛡️

上一节我们讨论了如何开始添加类型,本节中我们来看看如何保护已取得的成果。

为了防止已类型化的代码出现倒退(例如有人移除了类型注解),你需要利用类型检查器的严格模式设置。以Pyre为例:

  • 默认模式:允许函数缺少参数或返回类型注解。带有返回注解的函数会进行类型检查。
  • 严格模式:要求函数、参数、属性和全局变量都必须有类型注解。显式使用Any类型也会报错。

实施策略如下:

  1. 开始时将所有文件置于默认模式。
  2. 随着你逐步为一个文件添加完整的类型注解,将其切换到严格模式。这能防止他人无意中破坏该文件的类型完整性。
  3. 当项目成熟时,可以考虑将严格模式设为新文件的默认模式。

通过跟踪处于严格模式的文件比例,你可以清晰地衡量向完全类型化代码库迈进的进度。

推动类型采用的策略与工具 📈

上一节我们介绍了保护类型质量的机制,本节中我们来看看如何有效地在团队中推动类型采用。

在Facebook和Instagram的实践中,我们总结了一些有效策略:

以下是成功推动类型采用的关键步骤:

  • 获取团队认同:清晰传达类型化代码库的好处。
  • 设定明确目标:例如“到Q2末,使60%的函数具备返回类型注解”,这比模糊的“添加类型”更有效。
  • 认可与激励:通过仪表板展示贡献,举办“代码关爱日”等活动。
  • 引导新成员:让新工程师通过完成类型任务(如修复# type: ignore)来熟悉代码库。

为了衡量进展,可以使用pyre statistics命令来获取项目的类型覆盖率数据,包括注解数量、严格模式文件比例等。

此外,还可以利用自动化工具来加速这一过程:

  • Pyre Infer:静态推断代码类型并自动添加注解。
  • MonkeyType / PyAnnotate:根据运行时信息(如测试)生成类型注解。

处理棘手的代码模式与未来展望 🔮

上一节我们讨论了推广策略,本节中我们来看看实践中常见的挑战和类型系统的未来发展。

在为大型代码库添加类型时,会遇到一些棘手的模式:

1. 空容器初始化

# 可能被推断为 List[Any]
items = []
# 推荐的写法
items: List[str] = []

2. 细化可选属性

# 直接检查可能不够安全
if self.optional_attribute is not None:
    use(self.optional_attribute) # 类型检查器可能仍认为它是可选的
# 更好的做法(Python 3.9+可使用海象运算符)
if (attr := self.optional_attribute) is not None:
    use(attr)

3. 复杂的数据结构(如来自API响应的字典)
对于结构不清晰或值类型多样的字典,有时使用Dict[str, Any]在严格模式下也是一种务实的例外。

Python的类型系统正在不断进化,以提供更优雅的语法:

  • 新的联合类型语法(Python 3.10):int | str 替代 Union[int, str]
  • 更简洁的可调用对象和字典类型注解等提案也在讨论中。

总结 🎯

本节课中我们一起学习了Python渐进式类型的完整实践路径。

我们了解到,渐进式类型能为项目带来内置文档、减少生产错误等诸多好处。实施的关键在于:先从核心模块入手,利用严格模式保护成果,并通过设定明确目标、利用自动化工具和团队激励来有效推动。同时,Python类型生态的持续发展(如更简洁的语法)将使得这一实践越来越容易。

记住,为目标Python代码库添加类型是一个旅程,而非一次性的任务。通过逐步、系统地应用本教程中的策略,你可以显著提升代码的健壮性和可维护性。

011:下一代Python学习者与教育趋势 🐍

在本节课中,我们将探讨Python教育领域的当前趋势,了解新一代Python学习者的特点,并介绍一个全新的Python教育资源平台。我们将重点关注如何以公平、包容的方式教授Python,并探索社区可以如何支持这一代学习者。


教育中的Python:趋势与驱动力 📈

上一节我们概述了课程内容,本节中我们来看看影响Python在教育中应用的一些宏观趋势。

第一个趋势是“全民计算机科学”倡议的增加。这些倡议要求每个学生,无论其背景或兴趣如何,都必须在常规上学时间内接受计算机科学教育。这确保了计算机科学不再仅仅是一门选修课,而是所有学生都能获得的基础接触和理解。

第二个趋势是大学层面“计算机科学导论”课程的变化。非计算机专业的计算机科学课程在增加,一些大学甚至将其设为毕业要求。同时,许多院校也在重新设计计算机科学专业的入门课程。在这两种情况下,Python常被选作教学语言,作为一种促进教育公平的工具。

选择Python作为入门语言,是一个支持所有学习者的明确决定。Python的语法清晰,常被比作“伪代码”,这一特性降低了初学者的认知负荷,是其教育普及的重要驱动力。

以下是Python在教育中流行的几个关键原因:

  • 可读性强:Python代码对学生和教师而言都非常易读,便于理解和追踪程序逻辑。
  • 资源丰富:互联网上有大量高质量的教育资源可供教师使用。
  • 灵活通用:学生可以用Python在不同领域(如Web开发、数据分析、人工智能)创建项目,教师也可以采用多种编程范式进行教学。
  • 行业相关:Python在业界,尤其是数据科学领域的广泛应用,激发了学生的学习兴趣,也让教师感到他们在传授有价值的实用技能。
  • 促进数据素养:Python与当前教育中推动“数据素养”的趋势高度契合,教师可以同时教授编程和数据分析。
  • 活跃社区:围绕Python有一个强大且活跃的社区,这为教育资源的持续产生和共享提供了支持。

新一代Python学习者:特点与机遇 👥

上一节我们讨论了Python流行的原因,本节中我们来看看这些趋势所吸引的新一代Python用户是谁。

这一代用户比以往更加多样化,来源广泛:

  1. 中学生:由于“全民计算机科学”倡议,大量6-12年级的学生正在学校首次接触Python。
  2. 跨学科教师:不仅仅是计算机科学教师,许多数学、科学等学科的教师也在学习Python,以便将其融入自己的教学。
  3. 课外活动参与者:课后项目、编程夏令营等活动越来越受欢迎,吸引了更多年轻学习者。
  4. 大学生:各个专业的学生都可能通过必修或选修的计算机课程学习Python。
  5. 独立学习者:丰富的在线教育资源使得更多人能够自学Python。

作为一个社区,我们面临着巨大的机遇:如何将这些“曾经写过Python”的人,转变为Python社区的活跃成员和贡献者?这需要我们思考如何扩大圈子,如何欢迎并吸引这一代人。

这需要社区进行讨论,审视我们的文化:哪些部分对帮助新成员融入至关重要?哪些部分可以调整以更好地接纳新一代?这是一项需要持续思考和协作的工作。


Python社区的教育支持与资源 🛠️

上一节我们认识了新一代学习者,本节中我们来看看Python社区为支持教育做了哪些努力。

Python社区长期以来一直支持教育工作:

  • 会议活动:例如PyCon US设有教育峰会,PyCon UK有专门的教育专题。
  • PSF支持:Python软件基金会通过教育拨款支持项目,例如2019年的教育拨款以及将Python纳入“隐秘天才”项目教学大纲。
  • 开源项目:存在大量与教育相关的开源项目,例如:
    • Brython:允许在浏览器中运行Python。
    • EduBlocks:帮助学习者从图形化积木编程过渡到Python文本编程。
    • Pygame:一个用于教学的游戏开发引擎。
  • 社区内容:例如“Teaching Python”播客等。

现在,我们重点介绍一个由社区努力构建的新平台:Python教育门户

该网站由PSF资助,旨在为Python教育者、学习者和倡导者提供一个中心枢纽。你可以在 education.python.org 找到它。

该网站主要提供以下功能:

  • 寻找资源:为教授Python寻找课程计划、工具和指南。
  • 贡献资源:分享你创建或知道的优秀教育资源。
  • 学习教学法:获取关于包容性教学策略等主题的知识。
  • 连接社区:与对Python教育感兴趣的其他人士联系。
  • 参与贡献:作为一个开源项目,欢迎所有人参与改进。

网站结构清晰,顶部菜单栏提供了不同入口:

  • 工具包:针对特定实践问题提供指南,例如基于证据的教学策略、倡导计算机科学教育的行动指南、包容性实践指南等。
  • 资源:一个可搜索的资源库,可以按关键词或主题筛选,查找课程大纲、项目想法等。
  • 论坛:一个供社区提问、讨论和联系的平台。
  • 参与:提供通过社交媒体和其他方式参与社区活动的信息。
  • 贡献:说明如何为这个开源项目做出贡献。
  • 行为准则:明确遵循PSF行为准则,确保环境友好。


深入探索:教学策略与社区行动指南 🧭

上一节我们介绍了新的教育资源门户,本节中我们深入看看其中的两个实用工具包。

1. 基于证据的教学策略工具包
这个工具包回答了“如何有效地教授Python?”这一问题。它介绍了一些经过研究验证的教学方法。

以下是其中一个名为 “PRIM” 的策略,它代表 预测、运行、调查、修改、制作。这是一种适用于不同水平学习者的编程教学框架。

我们通过一个来自Replit平台上的Python入门课程的例子来理解:

# 任务:预测下面这行代码会做什么?
print("Hello, World!")
# 学生在此写下预测:这行代码会在屏幕上显示文字“Hello, World!”。

# 然后,学生运行代码,验证预测。

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/9a68805e24367ae580eaef1ec4b6ff04_8.png)

# 调查阶段:学生被引导测试代码的不同部分。
# 例如:如果将字符串改为“你好,Python!”,输出会是什么?
print("你好,Python!")

# 修改阶段:学生重用代码,但改变内容,使其个性化。
print("你好,[学生的名字]!")

# 制作阶段:学生运用这个概念,创建自己程序的一部分。
print("欢迎来到我的第一个Python程序!")
print("我今天学习了print语句。")

2. 倡导行动工具包
这个工具包主要为科技公司和专业人士设计,指导他们如何支持中学计算机科学教育。它包含三个方面的资源:

以下是个人可以采取的行动建议:

  • 倡导支持:使用工具包中的模板和指南,向本地学校或政策制定者倡导计算机科学教育的重要性。
  • 增加可见性:如果你属于在科技领域代表性不足的群体,请考虑在学生面前展示自己。年轻学生需要看到像他们一样的人也能从事这个行业,这能极大地影响他们的职业想象和学习选择。
  • 成为导师:在工作中指导新同事或实习生。招聘人才很重要,但保留人才同样关键,而导师制对此大有裨益。你也可以指导对编程感兴趣的年轻人。

总结与行动号召 ✨

本节课中,我们一起学习了Python教育的最新趋势,认识了更加多样化的新一代学习者,并探索了全新的education.python.org资源门户。

全球范围内,我们正在共同塑造中学计算机教育的未来。这是一个既令人敬畏又充满责任的机会,我们必须以公平和包容为核心来构建它。

现在就是把握和影响这一进程的时刻。

你可以立即采取的行动:

  1. 访问 education.python.org,探索资源。
  2. 考虑为网站贡献内容或代码(项目地址:github.com/psf/python-in-edu)。
  3. 参与社区活动,例如即将举办的导师冲刺活动。
  4. 思考如何在你所在的社区或工作中,支持下一代Python学习者的成长。

感谢你的学习。欢迎你加入Python教育社区,共同为所有学习者创造一个更包容、更有效的学习环境。

012:演讲 _ Meredydd Luff _ 为开发者撰写良好文档 - VikingDen7 - BV19Q4y197HM

在本节课中,我们将学习如何为开发者撰写高质量的文档。我们将探讨文档的重要性、不同类型的文档及其作用,以及如何通过与用户互动来持续改进文档。无论你是开发工具的作者、API提供者还是开源项目维护者,这些知识都将帮助你更好地服务开发者用户。

为什么文档至关重要?🤔

上一节我们介绍了课程概述,本节中我们来看看为什么我们需要关心文档质量。

我关心这个问题,因为我是Anvil的联合创始人之一。Anvil是一个完全使用Python构建全栈Web应用的框架。我们是一家开发工具公司,提供在线IDE和托管平台,让只懂Python的开发者也能构建和部署交互式应用。因此,我非常希望开发者在我们构建的项目中拥有良好且高效的体验。

你也应该关心文档,因为无论我们构建开发工具、为商业产品提供API,还是在GitHub和PyPI上发布开源项目,我们都希望它被使用。我们希望开发者能够:

  1. 发现我们的项目。
  2. 判断它是否能解决实际问题。
  3. 学习如何使用它。
  4. 解决使用过程中遇到的问题。

传统上,文档被视为最后两个方面的工具。但实际上,开发者文档对整个流程都至关重要。因为你的文档不仅仅是学习材料,它还是内容营销。当人们遇到实际问题时,他们会打开搜索引擎提问“我该如何做?”。你的文档就是这个问题的答案。因此,他们必须能够找到它。

无论开发者如何发现你的项目,他们的下一个任务都是判断它是否有用。这意味着他们需要理解你的项目究竟是什么。

文档是新用户的第一印象 👀

上一节我们讨论了文档作为内容营销的角色,本节中我们来看看文档如何帮助新用户理解你的项目。

新用户需要了解你的项目能做什么、有什么优势、包含哪些功能以及存在哪些限制。通常,发现这些信息的最佳途径就是查看文档。

这意味着对于新用户,你的文档描述了你的项目是什么。因此,当新用户点击你的文档并浏览目录时,如果他们能看到关于项目功能及其优势的清晰总结,将有助于他们做出更好的决策。这远比只看到一段简介、一个常见代码示例以及几页关于边缘案例的讨论要好。

以下是《Anvil手册》的目录结构示例,它涵盖了编辑器、用户界面、客户端代码、服务器代码、数据存储和部署。这是一个不错的总结。

我们第一次编写文档时犯了一个错误:按照代码库结构来组织文档。在Anvil中,数据存储、邮件发送、用户认证以及第三方服务集成都是同一类型的插件对象,因此所有相关文档都被放在了一起。这对新用户非常不友好,因为数据存储的重要性远高于了解集成了哪些第三方服务。吸取教训后,我们重新组织了文档,使目录能准确总结项目是什么以及能做什么。

文档是开发者的用户界面 🖥️

上一节我们了解了文档如何帮助用户入门,本节中我们来看看文档在用户深入学习时的核心作用。

现在,开发者已经发现了你的项目,认为它有用,并决定开始使用。他们将开始学习并解决问题。此时,你的文档将被充分利用。开发者软件与大多数软件不同,因为对于开发者来说,你的文档就是用户界面

大多数软件像汽车,用户大部分时间盯着仪表盘,手册则被遗忘在手套箱里。但对于开发者工具,情况不同。如果某人在你的API上编写代码,他们将花时间盯着自己的代码和你的文档。因此,如果你的技能或资源允许,让文档看起来美观是值得的。

以Twilio(一个用于电话通讯的API)的文档索引为例。它看起来像第二个主页,因为他们想清晰地展示它是什么以及能做什么。这就是他们的用户界面。

我们应该撰写哪些类型的文档?📚

上一节我们明确了文档作为用户界面的重要性,本节中我们来探讨文档的不同类型及其用途。

好的,我们已经讨论了文档的一些角色。接下来谈谈我们应该写些什么,因为并非所有文档都是一样的。

教程和参考文档不是一回事。一位名叫Danielle Pritchard的聪明人提出了一个文档分类框架。他将文档分为四类:

  • 教程:提供逐步指导。
  • 解释:说明其工作原理。
  • 操作指南:完成特定现实任务的逐步指南。
  • 参考文档:对其所有功能的枯燥但全面的描述。

Danielle甚至有一个2x2矩阵:教程和解释用于学习项目,操作指南和参考用于完成特定任务;教程和操作指南是逐步的,而解释和参考更偏理论。这是一个方便的框架,你可以在diataxis.fr上阅读更多内容。

但我天生对过于整齐的2x2矩阵持怀疑态度。

API文档与参考文档的区别 🔄

上一节我们介绍了文档分类框架,本节中我们来看看该框架的一个潜在问题,并区分两种关键文档类型。

在这种情况下,我认为参考文档类别存在的最大问题是,该框架试图将太多不同的东西塞入其中。

根据该框架,参考指南是对机械结构及其操作方法的技术描述。这听起来不错,我们确实需要这些。但它也说参考指南描述软件本身,包括API、类、函数等以及如何使用它们。我们显然也希望有这个,但一份文档真的能同时满足这两种需求吗?

我不这么认为。我将前者称为参考文档,后者称为API文档,它们不是一回事。为了说明原因,我们用一个例子。假设我编写了一个单元测试库并想要记录它。

像大多数单元测试库一样,它有一个夹具的概念,即在测试前运行代码设置环境,然后运行测试,最后运行清理代码。如果要描述这个机制以及如何操作,我们需要描述这个顺序,讲述这些组件如何协同工作。

相比之下,API文档描述的是代码对象。它描述类、函数、命令等。因此,你在API文档中写的每一件事都是关于一个代码对象的。这没有留出多少讲故事的空间。

如果我们要记录单元测试库,而我们只有API文档,我们将在哪里描述“设置-测试-拆解”这个序列?我们会把它放在@setup装饰器的API文档中吗?放在test函数里?还是@teardown函数里?我们会尝试把它复制粘贴到所有这些地方吗?

在API文档中没有合适的地方来讲述这个故事。如果我们尝试把这些参考文档塞进API文档的框架里,最终会得到我称之为“JavaDoc病”的东西。

警惕“JavaDoc病” ⚠️

上一节我们指出了API文档在讲述系统故事时的局限性,本节中我们来看看一个历史成功案例带来的副作用。

现在这有点不公平,因为JavaDoc非常棒。它是每一个现代API文档工具的鼻祖。JavaDoc于1995年推出,自那时起世界就改变了。它允许你这样编写文档:在函数定义前写一个块注释,其中包含一些文本和机器可读的标签(如@param@return)。一个名为JavaDoc的程序会遍历你的代码库,提取这些注释和函数定义,生成干净、一致、易于导航的HTML API文档。这真的很棒。

这使得编写文档更容易,因为你只需以稍微结构化的方式注释代码。这也使得保持文档更新更容易,因为你可以在代码旁边更改这些注释。长期以来,由于JavaDoc的存在,平均Java库的文档质量远超几乎所有其他语言。因此,这是一个巨大的成功。

但这个成功的问题在于,它挤出了其他形式的文档。即使在今天,如果你查看一个Java库,很可能所有或几乎所有的文档都是JavaDoc生成的。因此,你最终会得到像这样的东西:这是一个解析命令行参数的库的JavaDoc。

这是完全合格的API文档。它列出了这个包中的15个类,你可以点击其中任何一个,找到它的功能、参数和返回值。但这些类是如何协同工作的?哪些类调用哪些类?我们不知道。这是API文档,没有地方可以讲述那个故事。实际上,这个库设计得相当好,有一个类(ArgumentParser)几乎能满足你所有需求。你发现它了吗?当然没有。因为这是API文档,没有好的地方来呈现那个重要信息。

所以,API文档和参考文档是不同的东西,当你考虑文档时,它们应属于不同的顶级类别。

总结:文档类型对比 📊

上一节我们通过实例分析了“JavaDoc病”,本节中我们来系统总结API文档与参考文档的核心区别。

  • 参考文档描述系统。它描述机制。
  • API文档描述代码对象。它描述类、函数和命令。
  • 参考文档讲述一个故事。例如,“设置-测试-拆解”序列。
  • API文档单独描述一个代码对象。例如,@setup装饰器如何调用及其独立操作。
  • 参考文档应根据项目功能逻辑结构化
  • API文档是自动结构化的,因为它应该从你的代码中生成。

因此,我将它们视为文档中两个独立的顶级类别。现在,我们有很多类别,也有很多文档需要编写。

如何组织不同类型的文档? 🗺️

上一节我们明确了不同文档类型的区别,本节中我们来看看如何让它们协同工作,以支持用户的不同学习路径。

让我们思考一下它们如何协同工作。用户从头到尾阅读你所有API文档的可能性非常小。实际上,用户从头到尾阅读你所有教程的可能性也很小。

你的用户正在进行一段旅程,这段旅程将引导他们接触几种不同类型的文档。大概率如此。

以下是一个用户旅程的例子:

  1. 用户想知道该使用什么,看到一篇博客文章推荐你的项目。
  2. 他们访问你的项目,尝试了解如何将其用于某个任务。为此,他们需要一份操作指南。
  3. 他们开始深入,想修改一个函数调用,需要知道参数。这显然是API文档的工作。

你可以看到,他们正逐渐深入到更细致具体的文档中。但这是另一个旅程:

  1. 用户想知道如何完成某个任务,在谷歌上搜索并找到一份操作指南(记住,文档是内容营销)。
  2. 下一个问题是:“那是如何工作的?”这就是你的参考文档所讲述的故事。
  3. 他们读了一些内容,决定认真学习,于是进行了一步一步的整体教程。

同样,这是一个完全合理的旅程。但你可以看到,他们不仅在放大(深入细节),同时也在缩小(获取概览)。

我们希望让用户能够通过我们的文档进行任何他们需要的旅程。最简单的方法是超链接。从任何类型的文档链接到任何其他涵盖相同主题的文档。例如,在操作指南中提到一个主题时,链接到参考文档;在参考文档中讨论某个功能时,链接到相关教程。

链接不是唯一的解决方案。将某些类型的文档放在一起也很方便。例如,Anvil有一个用于用户认证的内置库。手册中关于该功能的部分,前两页是迷你教程,其余部分是关于其工作原理的参考。明确地说,这些页面并不试图同时充当教程和参考,因为那行不通。但将它们放在一起,我们可以方便地放大和缩小,以跟随用户需要采取的任何旅程。

如何改进文档?与用户交谈! 💬

上一节我们探讨了如何组织文档以支持用户旅程,本节中我们来看看持续改进文档的最佳方法:与用户交流。

所以,我们有几种不同形式的文档。显然还有很多事情要做。弄清楚接下来应该做什么可能很困难。因此,我想通过谈论解决这个问题的最佳方法来结束:与你的用户交谈

因为如果你倾听用户的声音,你会听到你的文档最需要改进的地方。他们可能不会用太多的言辞来表达,但如果你和他们进行对话,你就会知道。最简单的方法就是面对面交流。这是我的同事Bridget上次帮助用户时的场景。我非常想念这个。

但即使会议是面对面进行的,这也并不是很可扩展。所以你还需要在线进行一些工作。我们有一个论坛。Discourse是免费的、开源的,而且实际上很容易设置和自托管。我会推荐它。如果你不想麻烦,Stack Overflow也能工作。或者你可以设置一个Slack或Discord实例。实际上,请不要使用Slack。社区是封闭的,对话是短暂的,这是个问题。

因为每当开发者询问如何使用你的项目时,那就是你的文档中的一个错误报告。每当这个问题在一个公共的、可搜索的地方被回答时,这就是一种补丁。一个答案可以帮助任何在同一个问题上遇到困难的用户。更好的是,因为这个过程是由用户的问题驱动的,这些补丁自然会向你现有文档中的漏洞靠拢。

如果你使用的平台有投票功能(如Discourse的点赞或Stack Overflow的赞成票),那么你甚至可以获得一些数据,了解哪些补丁确实非常急需整合到你的主文档中。当然,并非每个问题都需要整合。有些问题太晦涩,或者与项目主题关系不大。但这没关系。因为如果你的问答是公开且可搜索的,那么它就构成了你文档的另一个支柱。

你的文档几乎变得自我修复,补丁会自然吸引到正确的地方。那些冷门问题会自我解决。

总结与要点 🎯

本节课中,我们一起学习了为开发者撰写良好文档的核心要点:

  1. 你的文档就是你的用户界面。它是你的营销材料,也是新用户对你产品的定义。请像对待重要资产一样对待它。
  2. 开发者文档在用户决策漏斗的顶部比你想象的更重要。它帮助用户发现并理解你的项目。
  3. 区分你正在撰写的文档类型。记住,API文档(描述代码对象)和参考文档(描述系统机制和故事)不是一回事,它们服务于不同的目的。
  4. 与用户交流。在公开、可搜索的平台上与他们交谈,因为他们将帮助你发现并填补文档中的空白。

通过应用这些原则,你可以创建出不仅信息丰富,而且真正对开发者友好、能有效支持他们从发现到精通整个旅程的文档。

013:从NumPy到PyTorch,API兼容的故事 🧠

在本节课中,我们将学习PyTorch如何借鉴并扩展NumPy的API。我们将探讨NumPy和PyTorch的核心概念、它们之间的异同,以及PyTorch为实现NumPy操作符所构建的支持架构。课程旨在让初学者理解这两个库的关系以及PyTorch的独特价值。

1:NumPy与张量操作简介 📊

上一节我们介绍了课程概述,本节中我们来看看NumPy的基础。NumPy是一个用于科学计算的Python库,其核心是多维数组对象,称为ndarray。在深度学习中,我们通常称其为张量(Tensor)。

一个简单的NumPy程序示例如下:

import numpy as np
# 创建矩阵(二维张量)
a = np.array([[1, 2], [3, 4]])
b = np.array([[5, 6], [7, 8]])
# 逐元素相加
c = a + b
# 矩阵乘法
d = np.matmul(a, b)

张量可以高效表示图像、文本等数据,是现代深度学习的基石。NumPy提供了超过一千个函数来操作张量。

从实现角度看,NumPy操作符分为两类:

  • 复合操作:由其他NumPy操作组合而成,通常在Python层实现。
  • 原始操作:拥有自己的内核(直接操作张量数据的底层函数),通常用C++实现。

例如,np.sinc函数是一个复合操作,其Python实现基于其他基本操作。而np.copysign则是一个用C++实现的原始操作。

2:PyTorch的扩展功能 ⚡

现在我们已经了解了NumPy,本节中我们来看看PyTorch。PyTorch也是一个用于张量操作的流行Python库,其用户界面与NumPy高度相似。

将之前的NumPy程序转换为PyTorch非常简单:

import torch
# 创建矩阵(二维张量)
a = torch.tensor([[1, 2], [3, 4]])
b = torch.tensor([[5, 6], [7, 8]])
# 逐元素相加
c = a + b
# 矩阵乘法
d = torch.matmul(a, b)

对于更复杂的操作(如FFT、矩阵分解),PyTorch也提供了相应的函数,且语法与NumPy几乎一致。PyTorch和NumPy张量之间可以轻松转换。

然而,PyTorch并非简单复制NumPy。它专注于实现社区常用的数百个操作符,并在此基础上增加了关键特性:

  1. 硬件加速器支持:PyTorch张量可以在CPU、CUDA(GPU)、TPU等设备上运行,语义保持一致。
    # 在CUDA设备上执行计算
    device = torch.device(“cuda”)
    a_gpu = torch.tensor([[1, 2], [3, 4]], dtype=torch.float32, device=device)
    b_gpu = torch.tensor([[5, 6], [7, 8]], dtype=torch.float32, device=device)
    c_gpu = torch.matmul(a_gpu, b_gpu)
    
  2. 自动求导:这是训练神经网络的核心功能。
    x = torch.tensor([1.0, 2.0], requires_grad=True)
    y = x * 2
    z = y.sum()
    z.backward() # 自动计算梯度
    print(x.grad) # 输出梯度值
    
  3. 计算图与优化:PyTorch支持将操作序列编译成计算图(通过TorchScript、FX等),从而实现跨操作优化并减少Python与底层C++之间的调用开销。

这些特性使PyTorch成为一个强大的深度学习框架。在幕后,PyTorch操作符主要用C++实现,并为可微操作提供了自动求导公式。

3:将NumPy操作移植到PyTorch 🔧

上一节我们介绍了PyTorch的独特功能,本节中我们来看看将NumPy操作符添加到PyTorch的具体过程。这通常需要三个步骤:

  1. 编写C++实现:为原始操作编写CPU和CUDA内核。
  2. 编写自动求导公式:如果操作可微,需定义其反向传播规则。
  3. 编写全面测试:确保操作符在各种条件下正确工作。

这项工作至关重要,因为PyTorch社区强烈期望能使用熟悉的NumPy功能,并且希望实现与NumPy保持一致。社区成员也积极参与了数十个操作符的移植工作。

为了高效完成测试,PyTorch开发了OpInfo测试框架。其核心是OpInfo类,它用Python描述一个操作符的元数据(如支持的数据类型)和生成测试输入的函数。

以下是该框架的关键优势:

  • 自动生成测试:基于OpInfo,可以自动为每个操作符生成针对不同设备、数据类型的测试用例,无需手动编写数百行代码。
  • 统一信息源:OpInfo是所有操作符功能的单一真实来源。
  • 易于扩展:支持新设备类型(如XLA)和新数据类型,测试会自动覆盖。

这个框架极大地提升了开发效率,确保了代码质量。

4:PyTorch与NumPy的差异 🤔

尽管追求兼容,但PyTorch在一些地方有意与NumPy保持差异,以维护自身的一致性和原则。以下是几个例子:

以下是PyTorch与NumPy在一些细节上的不同处理方式:

  • reciprocal(倒数)函数:对于整数输入,NumPy使用整数除法(如1/2=0),而PyTorch返回浮点数结果(1/2=0.5),以保持与Python和PyTorch除法行为的一致性。
  • eig(特征值)函数:对于实值矩阵,NumPy仅在特征值为复数时才返回复数张量。PyTorch为了一致性(便于计算图进行输出类型推断),总是返回复数张量。
  • 复数排序:NumPy允许对复数进行排序(先比较实部,再比较虚部)。PyTorch认为这在数学上不直观,因此直接抛出运行时错误,不支持此操作。

此外,还存在一些系统性差异,但社区反馈较少:

  • 类型提升规则:PyTorch更倾向于提升到浮点类型,而NumPy更倾向于双精度。
  • 返回值:NumPy的某些操作可能返回Python标量,而PyTorch始终返回张量。

这些差异都是有原则的,旨在维护PyTorch内部体验的一致性。

5:经验总结与未来展望 🚀

在本节课中,我们一起学习了NumPy和PyTorch的关系。让我们总结一下从移植NumPy操作中学到的经验教训:

以下是核心的三点经验:

  • 倾听社区:社区的反馈明确了既需要NumPy的功能,也需要实现与PyTorch整体体验保持一致。
  • 提升开发效率:像OpInfo这样的支持架构能节省大量开发和测试时间。
  • 坚持原则:在借鉴其他API时,必须清楚自己的设计原则,不能盲目复制,以免造成自身生态系统的不一致。

展望未来,PyTorch团队计划:

  1. 更多地修复与NumPy的不一致之处,而非一味添加新操作。
  2. 增加更多来自SciPy库的操作符。

总结
本节课中,我们一起学习了NumPy作为科学计算基础库的角色,以及PyTorch如何在其之上构建,通过支持硬件加速、自动求导和计算图,成为一个强大的深度学习框架。我们了解了PyTorch实现NumPy API的策略、其背后的技术支持,以及有原则地与NumPy保持差异的原因。理解这些将帮助你更有效地在两个库之间切换,并充分利用PyTorch进行深度学习开发。

014:何时异常不是异常?使用警告 🚨

在本节课中,我们将要学习 Python 中的警告机制。与会导致程序中断的异常不同,警告是一种温和的提醒,用于提示用户当前的操作虽然暂时可行,但未来可能引发问题。我们将探讨警告的用途、如何创建和过滤警告,以及如何在你的代码中有效地使用它们。


现实世界的类比:燃油警告灯 ⛽

在深入代码之前,让我们通过一个现实世界的例子来理解警告的概念。汽车有一个燃油表,当燃油即将耗尽时,仪表盘上会亮起一个黄色的警告灯。这个灯不会让汽车立即停止,但它会持续地、令人烦恼地提醒你:“你需要加油了,否则很快会抛锚。” 它旨在促使你改变行为,避免更严重的后果。

在软件中,我们也经常遇到类似的情况:用户或代码即将做一些可能导致未来错误的事情,但尚未造成实际损害。这时,我们就需要一个像“低燃油灯”一样的机制来发出警告。


为什么需要警告?🤔

上一节我们介绍了警告的现实类比,本节中我们来看看为什么 Python 需要单独的警告机制,而不是使用已有的异常或简单的打印语句。

使用异常的问题
异常就像一通必须接听的电话,如果你不处理(捕获)它,程序就会终止。这对于那些“未来可能出问题,但现在还能运行”的情况来说过于严厉了。我们不想因为一个非致命性的提醒而让整个程序崩溃。

使用打印语句的问题
简单的 print() 输出不够正式,容易与正常的程序输出混淆,并且无法被系统地过滤、捕获或重定向。

因此,Python 的 warnings 模块在“异常”和“打印”之间提供了一个完美的折中方案。它允许我们发出引人注目的提醒,同时保持程序的运行,并且提供了强大的过滤和控制能力。


如何发出警告?🚦

了解了警告的必要性后,我们来看看如何在代码中实际创建一个警告。

首先,需要从标准库中导入 warnings 模块。

import warnings

假设我们有一个简单的函数 hello(name),现在我们想修改它的 API,让它接受一个名字列表而不是单个字符串。为了给用户过渡的时间,我们可以在新版本发布前,在函数中添加一个警告。

以下是发出警告的方法:

# hello.py
import warnings

def hello(name):
    # 发出一个警告,提示API即将更改
    warnings.warn(
        "The 'hello' function will soon require a list of names as input.",
        UserWarning
    )
    return f"Hello, {name}!"

当用户调用 hello(“World”) 时,他们会在控制台看到类似下面的输出,但程序会继续执行:

hello.py:5: UserWarning: The ‘hello‘ function will soon require a list of names as input.
  warnings.warn(message, UserWarning)
Hello, World!

输出包含了文件名、行号、警告类别和具体消息,帮助开发者定位问题。关键是,函数调用正常完成了。


警告的类别与自定义 📂

Python 内置了多种警告类别,用于区分不同严重程度和类型的警告:

  • Warning: 所有警告类的基类。
  • UserWarning: 用户代码生成的警告(默认类别)。
  • DeprecationWarning: 针对已弃用功能的警告。
  • SyntaxWarning: 针对可疑语法的警告。
  • RuntimeWarning: 针对可疑运行时行为的警告。
  • FutureWarning: 针对未来语义会改变的警告。

warn() 函数中,第二个参数用于指定类别。最佳实践是创建自定义的警告类别,这能提供更清晰的语义并方便过滤。自定义警告类应继承自现有的警告类别(如 UserWarning)。

# 创建一个自定义警告类别
class MyFeatureWarning(UserWarning):
    """针对我的特定功能发出的警告。"""
    pass

# 使用自定义警告
warnings.warn(“This feature is experimental.”, MyFeatureWarning)

控制警告:过滤与行为 ⚙️

默认情况下,有些警告(如 DeprecationWarning)是被过滤掉的,不会显示。我们可以控制警告的行为。Python 定义了六种处理警告的“动作”:

  • default: 为每个位置(模块+行号)首次出现的警告打印输出。
  • error: 将警告转换为异常(引发)。
  • ignore: 忽略警告。
  • always: 总是打印警告信息。
  • module: 为每个模块首次出现的警告打印输出。
  • once: 只打印一次警告(程序生命周期内)。

我们可以通过多种方式设置过滤器:

1. 命令行方式
使用 -W 选项。例如,让所有 UserWarning 都显示:

python -W always my_script.py

让所有警告都变为异常:

python -W error my_script.py

2. 代码中设置
使用 warnings.filterwarnings() 函数。

import warnings
# 忽略所有警告
warnings.filterwarnings(‘ignore’)

# 将特定的自定义警告转为异常
warnings.filterwarnings(‘error’, category=MyFeatureWarning)

# 使用上下文管理器临时修改设置
with warnings.catch_warnings():
    warnings.simplefilter(“ignore”)
    # 在这里调用会产生警告的代码,警告将被忽略
    call_poorly_behaved_function()

3. 环境变量
设置 PYTHONWARNINGS 环境变量。

export PYTHONWARNINGS=“always::UserWarning”

何时使用警告?💡

警告是一个强大的工具,应在以下场景考虑使用:

  • API 变更:当你计划修改或移除某个函数、类或模块时,提前数个版本发出弃用警告,给用户充足的迁移时间。
  • 常见错误预防:如果用户常以错误的方式调用你的函数(例如,参数类型不对但尚可转换),可以用警告引导其使用正确方式。著名的例子是 Pandas 的 SettingWithCopyWarning
  • 实验性功能:对于尚未稳定的新功能,可以发出警告告知用户其接口或行为可能在将来改变。

核心原则是:对用户友好。清晰的警告信息应该指出潜在问题,并尽可能提供如何修复的指导。


总结 📝

本节课中我们一起学习了 Python 中的警告机制。我们了解到:

  1. 警告是一种介于异常和打印之间的通信机制,用于提示非紧急的、未来可能发生的问题。
  2. 使用 warnings.warn() 可以发出警告,并可以指定不同的内置或自定义警告类别
  3. 通过警告过滤器,我们可以精细地控制警告的行为(如显示、忽略或转为异常),可以通过命令行、代码或环境变量进行配置。
  4. 警告在API弃用预防常见错误标记实验性功能等场景下非常有用。

就像汽车的低燃油警告灯一样,善用警告可以帮助你的用户平滑过渡,避免未来“抛锚”在路边。在你的库或应用中适时地添加警告,是维护开发者友好性的重要实践。

015:如何在方法中自动插入 self

概述

在本节课中,我们将要学习Python中一个看似“魔法”的特性:当通过实例调用方法时,Python如何自动将实例本身作为第一个参数(即 self)插入。我们将深入探讨其背后的机制——描述符协议,并理解这并非无法理解的魔法,而是我们可以掌握和运用的强大工具。


P15:1:回到基础——理解 self 的“魔法”

上一节我们介绍了本课程的目标。本节中,我们来看看 self 这个基础概念,以确保我们处于同一起点。

Python具有层层叠叠的抽象和功能,使其成为一门高级语言。一些人抱怨语言中添加了太多“魔法”。然而,这种“魔法”旨在帮助开发者专注于业务逻辑,而非底层细节。使用“魔法”一词可能让人感觉无法理解其机制。但事实并非如此,许多“魔法”是我们可以理解和创造的。

为了揭示 self 的魔法,我们需要先回到基础。定义一个简单的类有助于我们观察。

class Guitar:
    def __init__(self, name):
        self.name = name

    def play_note(self, note):
        print(f"{self.name} is playing the note {note}.")

现在,我们可以创建实例并调用方法:

my_guitar = Guitar("Warwick Streamer")
my_guitar.play_note("C#")

输出将是:Warwick Streamer is playing the note C#.

这里有趣的是:play_note 方法定义时接受两个参数 selfnote,但调用时我们只提供了一个参数 "C#"。显然,"C#" 被用作 note 参数,那么 self 的值从何而来?这就是自动插入 self 的“魔法”。Python负责将我们调用的具体实例(my_guitar)作为第一个参数插入。

要理解这个魔法,我们需要探究方法究竟是什么,以及它们如何运作。


P15:2:方法与函数的关系

上一节我们看到了 self 被自动插入的现象。本节中,我们来探究方法与普通函数的关系。

当Python读取包含 play_note 方法的 Guitar 类定义时,会发生以下情况:

  1. Python创建一个类对象(Guitar)来表示这个类。
  2. 读取 def play_note... 语句时,它只是在内存中创建一个普通的函数对象
  3. 关键的一步是:这个函数对象的名称 play_note 被赋值给 Guitar 类的一个类属性

简而言之,在类中定义一个函数并不会创建一种特殊的“方法对象”,它只是创建了一个普通函数,并将其赋值给一个类属性。

当我们尝试访问这个属性时,魔法才开始发生。


P15:3:属性访问与绑定方法

上一节我们了解到方法最初只是作为类属性的普通函数。本节中,我们来看看通过不同方式访问它时会发生什么。

我们可以通过两种方式访问 play_note

  1. 通过类本身:Guitar.play_note
  2. 通过类的实例:my_guitar.play_note

以下是关键区别:

# 通过类访问,返回函数对象本身
print(Guitar.play_note)  # 输出: <function Guitar.play_note at 0x...>

# 通过实例访问,返回一个“绑定方法”
print(my_guitar.play_note)  # 输出: <bound method Guitar.play_note of <__main__.Guitar object at 0x...>>

当通过实例访问属性时,Python在实例上查找 play_note。如果找不到,它会继续在类中查找。更重要的是,这个查找过程可以通过描述符协议进行挂钩。Python的函数对象实现了这个协议。

因此,当通过实例(my_guitar)访问函数属性(play_note)时,描述符协议启动,将实例(my_guitar)绑定到函数上,从而创建一个绑定方法。在这个绑定方法中,实例已经作为第一个参数(self)被插入。这就是为什么调用时我们不再需要手动传递 self

现在,让我们通过引入描述符协议来揭示这个魔法背后的窍门。


P15:4:描述符协议简介

上一节我们提到了绑定方法背后的描述符协议。本节中,我们来初步了解什么是描述符。

描述符是能够修改我们在Python中与属性交互方式的对象。具体来说,它可以自定义属性的查找赋值删除行为。

描述符通过实现描述符协议来做到这一点,该协议包含三个特殊方法:

  • __get__(self, instance, owner): 自定义获取(查找)属性时的行为。
  • __set__(self, instance, value): 自定义给属性赋值时的行为。
  • __delete__(self, instance): 自定义删除属性时的行为。

描述符不需要实现所有三个方法。例如,Python的函数对象只实现了 __get__ 方法,这就是它能创建绑定方法的原因。

为了理解 __get__ 如何工作,我们将自己实现一个简单的描述符。


P15:5:实现一个简单的描述符

上一节我们介绍了描述符协议。本节中,我们通过一个简单例子来实现 __get__ 方法。

我们将创建一个 FavoriteDescriptor 描述符。将其作为类属性 is_my_favorite 分配给 Guitar 类后,当在吉他实例上访问该属性时,它会告诉我这个实例是否是我最喜欢的吉他(这里硬编码为“Warwick Streamer”)。

class FavoriteDescriptor:
    def __get__(self, instance, owner):
        # 如果通过类访问(instance为None),返回描述符本身
        if instance is None:
            return self
        # 否则,检查实例的name属性
        return instance.name == "Warwick Streamer"

class Guitar:
    is_my_favorite = FavoriteDescriptor()  # 描述符作为类属性

    def __init__(self, name):
        self.name = name

# 测试描述符
warwick = Guitar("Warwick Streamer")
fender = Guitar("Fender Jazz Bass")

print(warwick.is_my_favorite)  # 输出: True
print(fender.is_my_favorite)   # 输出: False
print(Guitar.is_my_favorite)   # 输出: <__main__.FavoriteDescriptor object at 0x...>

这个例子展示了 __get__ 方法的基本结构:它接收 instance(访问属性的实例)和 owner(拥有该属性的类)参数。通过检查 instance 是否为 None,我们可以区分是通过类访问还是通过实例访问。

现在,我们已经看到了描述符的一个实现,接下来可以看看Python中函数的 __get__ 方法是如何实现的。


P15:6:函数描述符的实现原理

上一节我们手动实现了一个描述符。本节中,我们来看看Python中函数对象的 __get__ 方法是如何工作的(用Python伪代码表示其逻辑)。

# 这是函数对象 __get__ 方法的简化逻辑
class Function:
    def __get__(self, instance, owner):
        if instance is None:
            # 通过类访问,返回函数本身
            return self
        else:
            # 通过实例访问,返回一个绑定方法
            return types.MethodType(self, instance)

其工作原理如下:

  1. 如果 instanceNone(意味着通过类访问,如 Guitar.play_note),则直接返回函数对象本身。
  2. 如果 instance 不是 None(意味着通过实例访问,如 my_guitar.play_note),则使用 types.MethodType 将函数(self)和实例(instance)绑定在一起,创建一个新的绑定方法对象并返回。

这就是 self 魔法的核心:函数是实现了 __get__ 方法的描述符。当你通过实例访问它们时,__get__ 方法被调用,并返回一个已将实例绑定为第一个参数的绑定方法。


P15:7:其他内置描述符示例

上一节我们揭示了函数描述符的秘密。本节中,我们来看看Python中其他利用描述符协议的内置工具,它们都通过包装函数来改变其行为。

以下是几个常见示例:

1. @classmethod 装饰器
它将函数包装起来,其 __get__ 方法总是将函数绑定到,而不是实例。

class MyClass:
    @classmethod
    def my_class_method(cls):
        print(f"Called from {cls}")

obj = MyClass()
obj.my_class_method()  # 输出: Called from <class '__main__.MyClass'>
MyClass.my_class_method() # 输出相同

2. @staticmethod 装饰器
它将函数包装起来,其 __get__ 方法总是返回原始函数本身,不进行任何绑定。

class MyClass:
    @staticmethod
    def my_static_method():
        print("Static method called")

obj = MyClass()
obj.my_static_method()  # 输出: Static method called
MyClass.my_static_method() # 输出相同

3. @property 装饰器
它允许你轻松地为属性添加getter、setter和deleter方法。一个巨大优势是:你可以先直接暴露实例属性,后期再根据需要添加 @property 来实现更复杂的逻辑,而无需改变类的公共接口。

class Guitar:
    def __init__(self, name):
        self._name = name  # “私有”属性

    @property
    def name(self):        # Getter
        return self._name.upper()  # 返回大写名称

    @name.setter
    def name(self, value): # Setter
        self._name = value.title() # 存储标题化格式

这些工具都非常强大,它们都基于描述符协议,让你能专注于高级逻辑。


总结

本节课中我们一起学习了Python中 self 自动插入背后的魔法。我们了解到:

  1. 方法最初只是作为类属性的普通函数。
  2. 当通过实例访问这些函数属性时,Python的描述符协议开始起作用。
  3. 函数对象实现了 __get__ 方法,该方法在通过实例访问时,会返回一个已将实例绑定为第一个参数(self)的绑定方法
  4. 描述符是一个强大的协议,允许自定义属性的访问、赋值和删除行为。@property@classmethod@staticmethod 等都是基于描述符构建的实用工具。

Python中的“魔法”并非不可触及。就像魔术师拥有咒语书一样,在Python中,我们拥有像描述符协议这样的“咒语书”,让我们能够理解、甚至创造自己的“魔法”。希望本课程能激发你进一步探索Python内部机制的兴趣。

016:打包 Python 应用以进行分发

概述

在本节课中,我们将学习如何使用 Briefcase 工具来打包 Python 应用程序,以便将其分发给没有 Python 经验的最终用户。我们将了解 Briefcase 如何将代码、依赖项和解释器捆绑成一个独立的单元,从而支持跨平台分发。


打包 Python 应用以进行分发

大家好,我叫拉塞尔·基思·麦吉恩。今天我在这里与你们谈谈分发。

你的蛇装进公文包中以便于运输。今天我在瓦朱克农戈·普查尔(即西澳大利亚珀斯)向你们讲话,我想认可瓦朱克农戈作为我录制的土地的传统所有者,以认可他们与土地、水域和文化的持续联系,并向他们致以敬意。

构建者的过去、现在和未来。所以去年在克利夫兰的 PyCon US 上,我非常荣幸地成为开幕主题演讲者。在那次演讲中,我谈到了 Python 作为一门语言所面临的挑战以及我们社区工具中的空白。其中一个空白与某种东西有关。

这似乎应该有一个明显的解决方案。如果你是某些 Python 代码的作者,当是时候将这些代码交给其他人以便他们可以运行时,你该如何做到呢?

对于这个问题,没有一个简单的答案,部分原因是分发对不同的人有不同的含义。如果你是一个库的作者,这个库是一个有良好定义 API 的 Python 代码集合,你希望其他人将其嵌入到自己的项目中,Python 确实为你提供了一个相当不错的答案。

这内置的功能。例如,像 Requests 这样的项目有一个明确的分发故事。该项目配置了一个 setup.py 和一个 setup.cfg 文件,当维护者想要发布新版本时,他们会为该新版本构建一个 wheel 并上传到 PyPI。作为最终用户,你可以通过 pip 安装 Requests,然后导入 Requests。

在你的代码中,然后开始发出请求。好的,Python 的打包生态系统偶尔会有一些小问题,但在大多数情况下,得益于 Python 打包机构的出色努力,像 PyPI、pip 和 twine 这样的工具工作得相当可靠。另一个分发的用例是 Python 项目。一个项目可能具有版本控制。

这可能是一个代码库,或者只是某个目录中的代码集合,但不会被上传到 PyPI。你从代码库获取代码,或者获取目录的副本,并有效地运行这个代码库或目录。像 PyCon US 网站这样的网站就是经典例子,但这并不是特定于网站的现象。其他软件也可以被分发。

作为一个项目。很多 Jupyter 笔记本在这个意义上是项目。它们是代码的集合,并不是为了商品化重用而设计。它们服务于一个单一的目的。这些项目是通过复制分发的,然后以某种方式部署。这个项目在任何传统意义上都没有被安装。

他们也没有单一的 Python 强制配置。他们可能在 requirements 文件中有一些配置,但即使那个名称也只是一个约定。而如何设置执行环境的问题则留作文档问题,通常假设用户对 Python 开发生态系统有一定的了解。

Python 生态系统中的另一个开发工具用例也是在 PyPI,就像一个库。你可以在开发环境中 pip install PyTest,然后可以导入 PyTest,以便为你的代码库添加参数化测试用例的固定装置,但由于 PyTest wheel 中的元数据,pip 还会安装一个入口点,让你从。

命令行。然而,这并不总是那么简单。如果你使用过 GitHub Pages,可能会遇到一个名为 Jekyll 的静态网站生成器。Jekyll 是用 Ruby 编写的,Jekyll 主页上的快速启动说明说你应该运行 gem install bundler jekyll。现在,我不是 Ruby 开发者,这意味着什么呢?当我发现另一个 Ruby 工具时,它会告诉我。

我是否已经安装了 bundle,以便将其他工具打包?

我拥有的版本是否兼容?如果这个新工具告诉我需要更新我的 Ruby 解释器,Jekyll 会继续工作吗?Jekyll 是用 Ruby 写的,但这并不是关于 Ruby 的问题。Python 工具也有完全相同的问题。这是一个分发问题。实现语言对最终用户几乎没有意义。

PyTest 的作者可以合理地认为你对 Python 生态系统有一定了解,因为 PyTest 用户几乎都是 Python 开发者,但如果你的用户不是 Python 开发者,比如说,哦,创建一个虚拟环境并运行 pip install my tool?这对任何不是已经有经验的 Python 开发者的人来说毫无意义,而对刚开始接触 Python 的人来说,这更是一个。

一直以来的混淆来源。坦白说,即使你的用户是 Python 开发者,这也不是一个好的用户体验。以 Black 这样的工具为例,除了少数例外,你可能只需要在计算机上保留一份。当更新发布时,你可能希望在所有地方使用该更新。但如果你将它安装到你的。

如果你激活了虚拟环境,系统 Python 将不可用或不可靠?

那么图形应用程序呢?随便选择你笔记本上的一个用户空间应用程序,比如 Slack。它们是用什么语言写的?谁在乎?

我并不想将 Slack 作为库来使用,我只想用它。我想以熟悉的方式安装它,点击一个图标,让应用每次都可靠启动。如果我更新另一个应用,比如 Firefox,我不想因为 Firefox 更新了共享库而导致我的 Slack 安装出现问题。

解释器。这些应用程序的最终用户真正关心的是安装的便利性,安装的便利性。让应用出现在启动菜单中的启动板上,并且有可能让该应用通过应用商店可下载,并定期更新。

渠道。每种类型的工具都有不同的分发需求,它们都需要在运行时使用 Python。但这些用例中只有第一个真正对 Python 生态系统有好的解决方案。即便如此,那种开发故事本质上假定你是一个有 Python 开发环境的 Python 开发者,并且你对操作它感到舒适。问题是如何。

当你将 Python 代码提供给最终用户时,如果最终用户对 Python 不感兴趣或不擅长设置和配置 Python 环境,那就是一个开放的问题。但这是一个非常重要的问题。它影响着用户对我们代码的体验。很高兴我们为第一个用例有了解决方案,但我们需要其他三个用例的可靠解决方案。

就我个人而言,我特别感兴趣的是最后一个,其次是第三个。我是 B-Web 项目的创始人,该项目旨在确保 Python 在一个日益移动化的计算世界中保持相关性。如果你正在为 iPhone 和 Android 构建应用程序,唯一的分发单位就是应用。

你无法在 iPhone 上使用 pip 安装。你无法在 Android 设备上安装系统版的 Python 并告诉用户创建一个虚拟环境。如果 Python 想在移动世界中保持相关性,我们需要一个涵盖应用分发的故事。并且,尽管我主要关注移动平台,但同样的故事实际上也适用于桌面平台。MacOS 和 Windows 一直。

过去有应用程序,但这些平台越来越鼓励通过应用商店以独立沙箱捆绑的方式分发应用。今天我将向你介绍 B-Web 项目对此问题的解决方案,而这个解决方案就是 Briefcase。Briefcase 是一个用于打包 Python 应用程序的工具。它将你的 Python 代码。

它将应用打包成一个独立单元,可以提供给没有 Python 经验的最终用户,以便他们可以在自己选择的平台上安装,而无需知道他们正在运行 Python 代码。Briefcase 是一个符合 PEP 518 的构建工具。如果你不知道这意味着什么,我建议查看 Brick Cannon 的这篇博客文章,但简而言之,它是一个。

这意味着它是一个使用 PyProject 进行配置的构建工具。它为 Windows 生成 MSI 安装程序,为 MacOS 生成 DMG 或原始应用包,为 Linux 生成 App Images,并生成可以上传到 Apple App Store 或 Google Play Store 的 iOS 和 Android 项目。它也具有高度的可扩展性。如果你想为其添加 flat pack 或 snap back。

Linux,你可以。或者如果你想支持一个全新的平台,比如机顶盒或手表,你也可以做到。现在,虽然它与 B-Wears 的 GUI 框架 toga 配合得很好,但并不需要它。你可以用 Briefcase 封装 Pyside 或 TK 交互应用。这个声明的警告是,Briefcase 的能力仅与框架本身一样好。Briefcase。

是一个打包工具。它无法让你的 TK 交互应用在移动设备上运行,因为 TK 尚未移植到移动设备。Briefcase 对命令行工具也不是很合适,至少目前还不是。它可能会被改编为命令行使用,我个人非常希望看到这一用例的支持,但目前对我来说,确切的支持形式并不明显。

这主要是 Briefcase 在内部工作方式的一个功能。Briefcase 使用的方法本质上是可能解决应用程序分发问题的最简单方法。Briefcase 应用是你的 Python 代码的完整副本、所有代码依赖项的完整副本,以及完整的 Python 解释器捆绑,以一种对你有意义的方式。

就是这样。Briefcase 大多是一个模板工具,结合了 PIP 的封装来安装你的 Python 依赖项,以及构建 DMG 或 MSI 文件或为分发签署应用程序所需的本地平台工具的封装。现在,Briefcase 并不是 Python 中唯一存在的应用程序打包工具,所以。

为什么你应该使用 Briefcase 而不是其他选项呢?首先,它并不试图聪明。一些替代 Briefcase 的工具,如 Pyoxidizer 或 Py installer 及其某些操作模式,将获取一个工作应用与创建可执行文件相卷积。为了支持创建可执行文件的目标,它们对你的代码做各种花招,把它打包成一个 zip 归档。

它嵌入到数据块中,并在运行时解压到内存时执行。当这能正常工作时,那是很棒的。但它并不总是有效,因为从根本上说,Python 代码是设计为通过解析解释器在代码目录上运行的。好吧,如果你对 Python 的导入系统有足够的了解,或者你知道这并不完全正确。

但是如果你在现实世界中拥有足够的 Python 实践经验,你会知道这在实践中是足够接近真实的,并且没有区别。如果你不相信我,我只需说一句 zip save X。另一方面,Briefcase 以 Python 设计的方式运行 Python,即在源代码目录上运行的解释器。Briefcase 唯一的事情就是你所支持的平台。

它的作用是自动化将工作解释器提供给最终用户的过程,以一种不需要他们了解任何 Python 的方式。Briefcase 也是跨平台的,这意味着你有一个单一的配置文件,可以生成适用于 MacOS、Windows、Linux、iOS 和 Android 的商店。这意味着你不需要为分发重复配置。

你的应用程序或消除潜在的错误源。好吧,够了,给我看代码。今天我将要做的是带你了解使用 Briefcase 打包项目的生命周期。如果你想要更详细的讲解,docs.bwe.org 上的 be where 教程有一个完整的演示,提供了比我能给你的更多解释。

在 25 分钟内。让我们开始一个新项目。我们创建了一个新的虚拟环境并安装了 Briefcase。我在这里提供的是 UNIX 规范的指令,但 Briefcase 也在 Windows 上运行。如果你想要了解如何转换,be where 教程为两者提供了命令。如果你想要开始一个全新的项目,Briefcase 有一个向导帮助你运行 Briefcase new。

你将会看到一系列问题,伴随一些解释性文本。你会被提示提供一系列细节。你将被提示填写一个在线表单。那是你展示给用户的应用名称,以及一个 Python 化的名称,是你将会 pip 安装的名称。

你将被问到一个用于命名空间的捆绑包,以将我的 hello world 与你的 hello world 区分开来。捆绑 ID 是应用商店使用的常见格式。它通常是你控制的域名的反向顺序。然后将该捆绑与应用名称结合,形成你应用的完整捆绑 ID。例如 org.bwe.hello world。

你将被询问项目名称。Briefcase 项目可以包含多个分发应用,因此你可以从一个代码库构建多个应用捆绑。但是如果你只打算有一个应用,你可以将表单名称用作项目名称。你将被要求提供项目的一行描述。你将被询问作者的姓名和在帮助文本中使用的电子邮件。

以及版权标签。项目的 URL,许可因为他的分发代码,你需要一个许可证。最后,还有一个 GUI 框架。Briefcase 为 toga 提供了一个模板,bewears 自己的 GUI 框架,但也有一个 pie side 模板以及一个空应用,你可以根据需要填充任何你想使用的框架。

你最终得到的,将是一个完整的停止项目,包括足够的代码来启动一个应用程序图标和多种格式以及一些项目元数据。你还会注意到,应用程序名称中的 DAP 破折号已经被规范化为下划线。所以应用名称 Hello Dash World 已被转换。

当它成为源代码源目录时,变为 Hello Underscore World。无论你选择哪个 GUI 框架,你都会得到相同的文件。应用的内容唯一不同的是 app.py、main.py 和 pie project.toml 中的一些值。这里特别关注的元数据在 pie project.toml 中。该文件包含有关应用本身的所有细节。

你的应用。此文件的内容将与您在向导中提供的答案相匹配。还有一个 built system 部分,这是 PEP518 的要求,声明这是一个 briefcase 项目。所有的 briefcase 选项随后在 tool.briefcase 部分中定义。项目级别的选项在该根级 tool.briefcase 中定义。各个应用则。

他们有自己的部分。因此,此配置文件将 Hello Dash World 定义为一个应用。这是应用名称,正式名称为 Hello World,等等。Hello World 应用的配置由项目设置和应用设置覆盖组成。因此,Hello World 应用将继承项目级别的版本定义。如果你想要有一个。

如果你想为此应用指定不同的作者名称,可以通过在应用的配置块中添加作者名称来实现。需要明确的是,这是一种 briefcase 特定的行为,并不是你会在其他 PEP518 工具中看到的。有两个例外情况适用于这种覆盖行为,即 sources 和 requires。

Sources 定义了你想包含在应用中的源代码目录列表。因此,这里我们表示源 Hello World 目录是此应用的一部分。整个文件夹将被递归复制到我们的打包应用中。对此的唯一要求是,你的源目录之一需要与。

Requires 定义了应用的 Python 依赖项。同样,这只是一个列表,采用你在 pip install 时使用的相同格式。Sources 和 requires 是一个累积设置。因此,如果你在项目级别指定了 sources 定义,任何应用级别的 sources 将被附加到该在项目级别定义的列表中。

图标也值得特别提及。你会注意到图标设置不包括文件格式扩展名。这是因为 briefcase 使用提供的值作为基础名称,并根据平台附加格式。因此,.ico 用于 Windows,.png 用于 Linux,等等。在某些平台上,你必须提供多个分辨率的多个图标,这些尺寸。

修饰符将附加到文件名以及扩展名。你还可以通过为该平台添加配置部分来指定特定于平台的选项。生成的 pipe project.toml 将包括所有支持的平台的部分:MacOS、Linux、Windows、iOS 和 Android。因此,这里我们指定了一个 pytool.briefcase.app。

helloworld。macos,部分。这些选项只会应用于该应用程序的 MacOS 构建。再次强调,sources 和 requires 等累积或其他设置将覆盖应用级或项目级的值。这里的内容是如果你在向导中选择 toga 时获得的内容,但如果你选择 PySide 或不使用框架,你将获得不同的内容。实际上,你甚至可以深入探讨。

如果你定义了一个工具。briefcase。app。helloworld。macos。dmg,这些设置只会应用于 MacOS 上的 dng 构建。例如,你可能这样做是为了指定 dng 安装程序的图标。如果你想在项目定义中添加第二个应用程序,你可以定义一个工具。briefcase。app。second,app 部分,然后继续为平台和打包格式等进行定义。

现在这个 stub py 项目.tom 文件是由 briefcase new 为你生成的,但你不必使用那个向导。如果你有一个现有项目,你可以从头开始自己创建 py 项目.tom 文件。不管你是如何创建的,你现在有了一个 briefcase 配置文件。我们如何使用它?

好吧,你可能首先想做的就是看看你的应用程序是否能运行。Briefcase 包含一个开发者模式,让你快速迭代配置。如果你运行 briefcase dev,Briefcase 将使用你的 py 项目.tom 文件来确定如何运行你的项目,然后在本地虚拟环境中运行它。第一次这样做时,它将查看你的配置。

文件,将安装你声明的所有要求,然后它将安装你的应用程序。现在请记住,briefcase 的设计动机是,最简单的事情也能成功。Briefcase dev 只是你当前平台要求列表的 pip install 的简写,接着是 Python minus M helloworld 运行你的应用程序。在后续运行中,依赖项。

步骤默认不会执行。Briefcase 只会自行启动项目。如果你添加或修改依赖项,你需要添加-D 标志以强制更新依赖项。如果你使用了向导,你将得到一个可工作的应用程序。它会是一个空窗口,但它将是一个可工作的应用程序,你可以现在在应用程序代码中进行迭代,添加功能。

修复 bug,无论你需要做什么。然而,最终时机会到来,当你准备好打包你的应用程序以进行分发时。第一步将是创建你的应用程序。为此,你需要调用 briefcase create。这将做一堆事情。首先,它将查看你当前的平台,并获取该平台的应用程序模板。

所以我在 MacOS 上运行,因此它将使用 MacOS DNG 模板。其次,它将获取一个支持包。支持包是一个可以嵌入到你的应用程序中的 Python 版本。对于 Windows,我们使用官方发布的 Python 嵌入包,其他平台则小心维护支持包。

不论你的平台是什么,briefcase 将下载支持包,解压到刚刚创建的应用程序模板中。briefcase 然后下载并安装你应用的依赖项,但它不会安装到你本地的虚拟环境中,而是安装到应用程序模板中。briefcase 接着安装特定于应用程序的部分或源代码。

你的应用和所有应用程序资源,如图标。然后就完成了。你现在拥有一个完整的应用程序模板。你的项目目录现在将包含一个平台文件夹,在这种情况下是一个名为 Macos 的文件夹。在这个文件夹里会有你项目中每个应用的文件夹。下一步是构建那个应用。我相信你会震惊地听到执行此操作的命令。

是 briefcase build。现在在 Macos 上,这实际上什么也不做,因为生成的应用程序或应用程序模板实际上是可执行的。Macos 应用只不过是一个特定格式的目录,里面有一些已知位置的元数据。在其他平台上,可能需要进行一些编译,而 briefcase 管理器负责调用那个编译器。

在这个时候,你的项目中会有一个与平台匹配的文件夹,该文件夹将是一个应用程序,以及作为构建过程一部分创建的任何文件。在这种情况下,它是一个 Macos 应用。如果你点击那个图标,一个应用程序会启动。或者你也可以保持控制台模式,使用 briefcase。你可以运行你的应用,你错过了它。

briefcase run。你应该看到一个正在运行的应用程序。现在这不是一个非常有趣的应用程序,但它是一个正在运行的应用程序。不论在你所选的平台上这意味着什么。在 Macos 上,这意味着任务栏中的一个图标,以及与应用程序名称匹配的应用程序菜单。

最后一步是使用 briefcase package 打包你的应用。这是分发之前需要完成的任何最终打包工作。因此,创建安装程序、进行代码签名等等。代码签名支持目前处于早期开发阶段。目前只有 Macos 应用被签名,并且目前仅被签名而未经过公证,如果你知道这是什么意思的话。

这是一个需要更多工作的领域,但所有的组件都已到位。但在打包之后,项目中的平台文件夹将包含一个可以上传进行分发的工件。一个 dmg 文件,一个 MSI,或任何适合所选平台的文件。因此,我们打包了我们的应用,发现了一个问题。我们需要更新我们的代码。我们需要吗?

重新经历这个整个过程吗?不需要。为此有 briefcase update。默认情况下,这将重新安装应用程序的代码。如果你也想更新依赖项,可以指定-d。如果你想更新应用程序资源,比如图标,可以指定-r。briefcase 还有一些其他值得注意的功能。briefcase create 是一个简写。

你的平台是从你当前运行代码的平台隐含得出的。输出格式是该平台的默认输出格式。如果你想要 Mac,Briefcase create 是 Briefcase create Macos DMG 的简写。如果你想创建不同的输出格式,比如你想要一个原始应用而不是 DMG 文件,你可以调用 Briefcase create Macos app。

如果你想针对完全不同的平台,Briefcase 创建平台。实际上,这并不是特别有用,因为你不能在 Mac 上创建 Macos DMG 或 Windows MSI,因为所需的工具是平台特定的。但这有一个重要的用例,我们稍后会提到。Briefcase 还将暗示早期步骤如果。

它们是必要的。如果你有一个全新的项目,刚从 Briefcase 新建并运行 Briefcase run,Briefcase 会检测到有一个尝试延迟并创建它,然后构建并运行它。Briefcase run 还允许使用 -U 选项,在执行之前更新应用。因此,你的开发周期可以短到不断重复 Briefcase run -U。

Briefcase 还会检查你是否具备编译所需的工具,并尽可能为你管理下载这些工具,接受任何许可等等。如果你无法为自己安装工具,比如在 Mac 上无法安装,你必须通过 App Store 获取 Xcode,至少它会明确告诉你需要获取哪些工具以及去哪里获取。

获取它们。如果你不确定自己的选项,可以添加 --help 来获取详细信息。现在,Briefcase 还做的一件事情是部署到移动平台。这是指定平台有效且必要的唯一地方。如果你想创建 iOS 应用,步骤与创建任何其他应用完全相同,你只需添加。

iOS 的所有命令都指定不同的平台。Briefcase create iOS,Briefcase build iOS,等等。对于 Android 也是一样,Briefcase create Android。iOS 构建仅在 Mac 上工作,因为底层工具的要求,但 Android 构建将在 MacOS、Linux 和 Windows 上工作。构建和运行步骤也稍有不同。

编译应用程序时,你需要针对特定设备。如果运行 Briefcase run iOS,Briefcase 会检查你的系统,找出可用的设备,并询问你想要针对哪个设备。如果你想简化这个问题,可以指定 -d 来提供设备 ID 或设备描述。

iPhone 11 或 iPhone 11 运行 iOS 11.3。如果有任何模糊之处,请说明是否有多个 iPhone 11 模拟器在运行,否则会要求你解决差异。Android 也是如此,创建 Android 模拟器或任何这些,从模拟器开始是必要的。一旦你的应用运行,它就像正常的 Python 代码一样以正常的方式运行。

但在运行时访问一些打包元数据可能会很有用,并提供支持。这个 Briefcase 生成符合 pep566 的打包元数据。如果你有一个 hello world 应用,你可以使用 importlib.metadata 访问你的 Briefcase 元数据。importlib.metadata 是在 Python 3.8 中添加的,但可以为旧版本的 Python 使用向后兼容的补丁。

这些键并不完全相同,它们符合 Briefcase 规范。例如,应用名称是用大写字母 N 命名的,但这是为了符合 pep566 的兼容性。好的,Briefcase 听起来很棒。那么有什么问题呢?

好吧,确实有很多方面可以改进各个独立平台。Linux 应用镜像目前不支持桌面条目。Windows 应用目前没有代码签名,且当前报告的系统检查器为 Python.exe。iOS 目前无法部署到物理设备。Android 支持不进行代码。

模拟器的签名。MacOS 不需要公证。这些都是可解决的问题。不过,它们需要时间、关注,在某些情况下需要一点专业知识或至少一些研究,以确定我们需要在编译命令中传递什么额外选项。最大的缺点,双关语,支持包的大小。

默认的 Briefcase 支持包包括完整的 Python 安装,这意味着 MacOS 上的 Briefcase 应用大约是 200 兆字节。在应用程序领域,这并不是不寻常的。Slack 是 174 兆字节,但他们的做法并不能算是一个好借口。不过,好消息是这也可以得到解决。这个 Hello World 应用包含了整个 Python 标准库。它包括 bzip2 和。

h2dplib 以及所有这些,由于许多应用从未使用过,因此这里有很多可以优化的地方。立即的解决方案是,Briefcase 允许你指定自己的支持包。因此,一旦你知道你的应用只需要标准库的特定子集,你可以构建一个手动调优的支持包并使用。

这样一来,Hello World 的大小轻松减少到约 30 兆字节,努力的话可以低至 15 兆字节。不过,这确实需要手动调优,而目前手动调优的过程并不是很用户友好。还有很多事情可以做来改善用户体验。Python 核心团队还间歇性讨论了一个内核 Python。这个想法。

一个官方的 Python 发行版,即最低可行的 Python,其余标准库根据需要导入。这对于 Briefcase 来说是绝对的黄金。Briefcase 在功能上并不是完全的。它工作得很好,但有很多方面可以改进。在 HAST 期间,我已经标记了一些现有平台明显的潜在改进。

但我们也可以为新平台添加支持,比如机顶盒和智能手表,或者为其他打包格式添加支持,比如在 Linux 上使用 flatpack 或 snap。实际上,添加这些后端并不是太复杂。它主要需要一些专业知识或研究,了解如何驱动这些平台的打包工具。我也希望能为命令行应用提供一个解决方案。

目前这更多是一个设计问题,而不是技术限制。Briefcase 的应用分发模型对命令行应用意味着什么?

我的大部分测试也基于 TOGA,但潜在的最大改进领域之一是测试支持是否适用于其他 GUI 框架。就我个人而言,我特别感兴趣的是测试游戏库,如 Pi Game 或 Pursued Pi Bear,因为我认为 Python 游戏是 Briefcase 最有潜力的领域之一,使游戏能够轻松分发给玩家,而不是开发者,甚至可能分发到移动应用商店。

最后,应用发布。Briefcase 目前的流程止于打包你的应用。然而,所有应用商店都有支持自动发布的 API,因此 Briefcase 也可能管理这一过程。是否存在一个一键发布流程,推送到 Steam?

听起来有趣吗?不过,添加所有这些功能依赖于有人有时间去改进。我希望能够花更多时间来开发 Briefcase 和 Beware 项目,但目前这主要是我在业余时间做的。如果你愿意,支持我在 Briefcase 和 Beware 项目上的工作,你可以作为财务成员加入该项目。

你也可以在 GitHub 赞助我。这个收入目前足以覆盖贴纸、托管等费用,但远不足以让我将其作为全职工作。如果你有想法或经验,可以帮助开源项目的开发资金,或者你想了解更多关于该项目的信息或参与其中,请随时联系我。

非常感谢大家,希望明年能在线上见到你们,再希望能亲自见面。[BLANK_AUDIO]


总结

在本节课中,我们一起学习了如何使用 Briefcase 工具来打包 Python 应用程序。我们了解了 Briefcase 如何通过捆绑代码、依赖项和 Python 解释器来创建独立的应用程序包,从而简化了向非 Python 开发者分发应用的过程。我们还探讨了 Briefcase 的配置、开发流程以及它在不同平台上的应用,并讨论了其当前的优势和未来的改进方向。

017:教程

在本教程中,我们将一起探索 Python 打包生态系统的核心构建模块。我们将了解代码如何从开发者的机器分发到用户的机器,并学习构建和分发 Python 库与应用程序的基本原理。课程内容将涵盖虚拟环境、导入系统、构建工具以及打包格式等核心概念。


1:Python 打包概述

Python 代码易于编写,但将其分发到其他机器并运行则可能具有挑战性。本教程旨在帮助你理解 Python 打包生态系统的全貌,了解各个组件如何协同工作,从而在遇到问题时知道如何排查。

打包的核心目标是分发代码。我们将以一个计算圆周率 π 的简单程序作为演示案例。计算 π 的一种简单方法是使用格雷戈里-莱布尼茨级数:

公式

π ≈ 4 * (1 - 1/3 + 1/5 - 1/7 + 1/9 - ...)

以下是一个简单的实现:

代码

def approximate_pi(iterations: int) -> float:
    pi_approx = 0.0
    sign = 1
    for i in range(1, iterations * 2, 2):
        pi_approx += sign * (1 / i)
        sign *= -1
    return pi_approx * 4

if __name__ == "__main__":
    print(f"π 的近似值 (300 次迭代): {approximate_pi(300)}")

运行这段代码有两种方式:作为脚本直接执行,或作为库在 Python 解释器中导入使用。这两种方式对应着不同的打包需求:应用程序模式库模式


2:理解 Python 解释器与虚拟环境

上一节我们介绍了两种代码使用模式。为了理解如何安装包,我们首先需要知道 Python 如何找到并导入代码。这离不开对 Python 解释器和虚拟环境的理解。

Python 解释器有多种类型。最常见的是系统解释器,通常通过操作系统包管理器安装。然而,更推荐的做法是使用虚拟环境,它能为项目提供独立的依赖管理。

创建虚拟环境主要有两种方式:

  • venv 模块:Python 3.3+ 的标准库模块,无需额外安装。
  • virtualenv:第三方工具,速度更快,且默认提供更新的 pipsetuptools

以下是两种方式的对比示例:

代码

# 使用 venv 创建环境(较慢,包版本较旧)
python -m venv my_venv_venv

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_63.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_65.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_67.png)

# 使用 virtualenv 创建环境(较快,包版本较新)
virtualenv my_venv_virtualenv

虚拟环境的本质是一个独立的 Python 解释器,它拥有自己的 site-packages 目录来安装第三方包,同时可以选择性地共享系统解释器的标准库。


3:Python 导入系统揭秘

上一节我们了解了代码运行的“沙盒”——虚拟环境。本节中,我们来看看 Python 解释器是如何在这个沙盒中找到并加载我们的代码模块的,即 导入系统 的工作原理。

当你执行 import something 时,Python 解释器并不知道 something 是否存在。它会按照一个名为 sys.meta_path 的查找器列表依次询问,直到某个查找器能够处理这个导入请求。

我们可以查看这个列表:

代码

import sys
print(sys.meta_path)

这些查找器会从一系列路径中搜索模块,这些路径存储在 sys.path 列表中。sys.path 通常包括:

  1. 当前脚本所在目录(空字符串表示)。
  2. 环境变量 PYTHONPATH 指定的目录。
  3. 标准库路径。
  4. 第三方包安装路径(site-packages)。

代码

import sys
print(sys.path)

当你在虚拟环境中导入一个包时,解释器会优先查找该虚拟环境自己的 site-packages 目录。因此,安装一个库,本质上就是将正确的 Python 文件(及元数据)放入目标环境的 site-packages 目录中。


4:打包格式:源码分发与 Wheel

理解了代码如何被找到和加载后,我们来看看代码以何种形式进行分发。从开发者的源代码到用户的 site-packages,代码通常以两种格式进行传输:源码分发Wheel

  • 源码分发:通常是一个 .tar.gz 压缩包,包含了开发源代码树中的大部分文件(如业务逻辑、测试文件、许可证等),但不一定包含项目管理文件(如 CI 配置)。用户拿到后需要先“构建”才能安装。
  • Wheel:通常是一个 .whl 文件,本质上是一个 Zip 归档。它包含了即将被直接安装到 site-packages 中的所有文件(编译后的 .pyc 文件、二进制扩展模块、元数据等),不包含测试或源码。用户可以直接安装,无需构建。

关键区别:Python 打包生态系统总是以安装 Wheel 为目标。如果用户获得的是源码分发,安装工具(如 pip)会先从中构建出一个 Wheel,然后再安装这个 Wheel。因此,为你的库分发预构建的 Wheel 可以显著加快用户的安装速度,并避免用户环境缺少编译工具的问题。

典型的打包与安装流程如下:

  1. 开发者:源代码 -> 构建 -> 生成源码分发和/或 Wheel -> 上传 -> 中央仓库(如 PyPI)。
  2. 用户:从中央仓库 发现并下载 包 -> 如果是 Wheel 则直接 安装;如果是源码分发则先 构建 成 Wheel 再安装。

5:构建工具:历史与现状

上一节我们了解了包的最终形态。本节中我们来看看这些形态是如何从源代码构建出来的,即 构建工具 的演变。

Python 打包构建方式经历了以下演进:

  1. distutils:Python 早期标准库模块,使用 setup.py(Python 代码)进行配置,灵活性高但不易标准化。
  2. setuptoolsdistutils 的增强版,成为事实标准,引入了 setup.cfg(配置文件)来补充声明式配置。
  3. wheel:2014年引入的二进制打包格式,旨在加速安装并提高安全性。
  4. flit / poetry:现代工具,倡导使用声明式配置(如 pyproject.toml),并试图提供从构建到发布的一体化体验。

现代构建的核心规范是 PEP 517PEP 518。它们将构建过程分为:

  • 构建前端:负责创建隔离的构建环境并安装构建依赖。例如 pipbuild
  • 构建后端:负责在构建环境中实际执行打包操作。例如 setuptoolsflit-corehatchling

前后端通过 pyproject.toml 文件进行协调。该文件必须包含 [build-system] 部分来声明构建依赖和使用的后端。

代码 (pyproject.toml 示例):

[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"

使用标准前端 build 进行构建的命令很简单:

代码

python -m build


6:动手实践:使用 Flit 打包库

理论介绍完毕,现在让我们动手实践。我们将使用一个简单的现代构建工具 Flit 来打包我们计算 π 的库。

首先,确保你有一个可用的 Python 环境,并使用 pipx 安装 flit(推荐方式,避免污染全局环境):

代码

# 安装 pipx(如果尚未安装)
python -m pip install --user pipx
python -m pipx ensurepath

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_165.png)

# 使用 pipx 安装 flit
pipx install flit

接下来,在项目目录中初始化配置:

代码

flit init
# 根据提示输入模块名、作者等信息

这会在 pyproject.toml 中生成配置。我们需要确保模块文件(如 pi_approximate.py)顶部包含 __version__ 变量。然后即可构建包:

代码

flit build

此命令会在 dist/ 目录下生成 .whl(Wheel)和 .tar.gz(源码分发)文件。

最后,你可以在一个干净的虚拟环境中测试安装:

代码

# 创建虚拟环境
python -m venv test_env
source test_env/bin/activate  # Linux/macOS
# 或 test_env\Scripts\activate  # Windows

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_208.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_210.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_212.png)

# 安装刚构建的 Wheel
pip install dist/your_package_name.whl

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_214.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_216.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_218.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_220.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_222.png)

# 测试导入
python -c “import pi_approximate; print(pi_approximate.approximate_pi(100))”


7:性能优化与二进制扩展

我们的库目前是纯 Python 的。有时为了追求极致性能,我们需要将部分代码编译成二进制扩展。本节我们来看看如何通过 Cython 将 Python 代码编译成 C 扩展。

首先,我们需要切换到支持构建 C 扩展的后端,例如 setuptools。更新 pyproject.toml 中的构建后端,并创建 setup.pysetup.cfg 文件来配置项目。

关键步骤是在 setup.py 中配置 Cython 扩展:

代码 (setup.py 示例):

from setuptools import setup
from Cython.Build import cythonize

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_277.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_279.png)

setup(
    ext_modules = cythonize(“pi_approximate.py”)
)

同时,在 pyproject.toml 中声明构建依赖:

代码 (pyproject.toml 片段):

[build-system]
requires = [“setuptools>=42”, “wheel”, “Cython”]
build-backend = “setuptools.build_meta”

现在,使用 python -m build 构建时,setuptools 会自动调用 Cython 将 pi_approximate.py 编译成 .c 文件,再编译成平台相关的二进制文件(如 .pyd.so),并打包进 Wheel。

注意:包含二进制扩展的 Wheel 是平台特定的(名称中包含类似 cp39-win_amd64 的标签)。你需要为每个目标平台(操作系统、CPU架构、Python版本)分别构建 Wheel,用户才能获得无需编译的安装体验。


8:打包 Python 应用程序

之前我们主要关注作为的打包。本节我们来看看如何打包独立的 应用程序。应用程序对用户的要求更低,理想情况下用户只需运行一个文件即可。

Python 本身支持 Zip 应用:将一个包含 __main__.py 文件的 Zip 归档交给 Python 解释器,它可以直接运行。

代码

# 创建一个包含应用代码和 __main__.py 的 zip 文件
python -m zipapp my_app -m “my_app:main”

# 运行这个 zip 应用
python my_app.pyz

但 Zip 应用不处理依赖项。为此,社区提供了更强大的工具,如 ShivPEX。它们能将你的应用及其所有依赖打包成一个可执行的 Zip 文件。

代码 (使用 Shiv):

# 安装 shiv
pip install shiv

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_325.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_327.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_328.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_330.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_332.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_334.png)

# 为你的包(及其依赖)创建 zipapp
shiv -c pi_approximate -o pi_app.pyz your_package_name

对于终极用户体验,可以使用 PyInstallercx_Freeze 等工具,它们能将 Python 解释器、你的代码以及所有依赖打包成一个独立的可执行文件(如 .exe),用户完全无需安装 Python。

代码 (使用 PyInstaller):

# 安装 pyinstaller
pip install pyinstaller

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_347.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_349.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_351.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/759a25f61cc4dcebc6324f15f7d3b776_353.png)

# 打包你的脚本
pyinstaller --onefile pi_approximate.py

生成的独立可执行文件位于 dist/ 目录下,可以直接分发给用户。

总结:选择哪种方式取决于你的目标用户。对于懂技术的用户,Zip 应用或 Shiv/PEX 包是轻量级选择。对于完全不懂 Python 的最终用户,PyInstaller 生成的独立可执行文件是最佳选择。


总结

在本教程中,我们一起深入探索了 Python 打包的奥秘。我们从 Python 代码的运行方式(库 vs 应用)和基础环境(虚拟环境)讲起,深入了解了 Python 导入系统的工作原理。接着,我们剖析了两种核心的打包格式(源码分发和 Wheel),并回顾了构建工具的历史与基于 PEP 517/518 的现代构建流程。通过动手实践,我们使用 Flit 打包了一个简单的库,并探讨了通过 Cython 编译二进制扩展来优化性能。最后,我们学习了如何为最终用户打包独立的应用程序,从简单的 Zip 应用到包含所有依赖的独立可执行文件。

希望本教程能帮助你建立起对 Python 打包生态系统的清晰认知,让你在分发自己的 Python 项目时更加自信和得心应手。

018:装饰器入门

概述

在本节课中,我们将要学习Python中的装饰器。装饰器是一种强大的工具,它允许你修改或增强函数的行为,而无需更改函数本身的代码。我们将从基础概念开始,逐步学习如何编写和使用装饰器,最终理解它们如何让你的代码更简洁、更可重用。


章节1:什么是装饰器?🎯

装饰器本质上是一个函数,它接收另一个函数作为参数,并返回一个新的函数。使用装饰器可以在不修改原函数代码的情况下,为其添加额外的功能。

一个简单的例子

让我们从一个简单的例子开始,看看装饰器能做什么。假设我们有一个计算缓慢的函数,我们想缓存它的结果以提高效率。

import time
from functools import lru_cache

def slow_square(n):
    """一个模拟耗时计算的函数"""
    time.sleep(3)  # 模拟长时间计算
    return n * n

# 不使用装饰器,每次调用都很慢
print(slow_square(3))  # 等待3秒后输出 9
print(slow_square(3))  # 再次等待3秒后输出 9

现在,我们使用标准库中的 @lru_cache 装饰器来缓存结果。

@lru_cache(maxsize=None)
def slow_square(n):
    time.sleep(3)
    return n * n

print(slow_square(3))  # 第一次调用,等待3秒后输出 9
print(slow_square(3))  # 第二次调用,立即输出 9(结果已缓存)
print(slow_square(4))  # 新参数,等待3秒后输出 16

通过这个例子,我们可以看到装饰器的两个关键优势:

  1. 语法简洁:使用 @decorator_name 的语法,清晰明了。
  2. 代码复用@lru_cache 可以轻松应用到任何需要缓存的函数上,无需在每个函数内部编写缓存逻辑。


章节2:理解函数作为一等公民 🧱

上一节我们介绍了装饰器的外观和作用。要深入理解装饰器如何工作,我们需要先了解Python中“函数是一等公民”这个概念。

这意味着函数可以像整数、字符串等对象一样被处理。具体来说,你可以:

  • 将函数赋值给变量。
  • 将函数作为参数传递给另一个函数。
  • 从一个函数中返回另一个函数。

将函数赋值给变量

def greet(name):
    return f"Hi {name}"

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_13.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_15.png)

# 将函数赋值给新变量
say_hello = greet
print(say_hello("Alice"))  # 输出: Hi Alice

将函数作为参数传递

def shout(text):
    return text.upper() + "!"

def whisper(text):
    return text.lower() + "..."

def greet(name, formatter):
    """formatter 参数接收一个函数"""
    message = formatter(f"Hello {name}")
    print(message)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_17.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_18.png)

greet("Bob", shout)   # 输出: HELLO BOB!
greet("Bob", whisper) # 输出: hello bob...

函数引用 vs. 函数调用

这是一个非常重要的区别:

  • function_name 是对函数本身的引用
  • function_name()调用函数,并获取其返回值。

print(greet)    # 输出: <function greet at 0x...> (函数对象)
print(greet())  # 错误!缺少参数。如果 greet() 返回 None,则输出 None。

在传递函数时,我们传递的是它的引用(不带括号)。


章节3:创建简单的装饰器 🛠️

基于上一节的知识,我们现在可以尝试构建自己的装饰器。装饰器的核心模式是:一个接收函数作为参数的函数,它内部定义并返回一个新的“包装”函数。

第一个装饰器:反转输出

让我们创建一个装饰器,它使被装饰的函数将其输出文本反转。

def reverse_factory(func):
    """这是一个装饰器工厂,它返回一个装饰器函数。"""
    def wrapper(text):
        # 调用原函数,但传入反转后的参数
        result = func(text[::-1])
        return result
    return wrapper

# 使用装饰器
@reverse_factory
def greet(name):
    return f"Hi {name}"

print(greet("Python"))  # 输出: Hi nohtyP

@reverse_factory 语法是以下代码的简写(语法糖):

def greet(name):
    return f"Hi {name}"
greet = reverse_factory(greet) # 用装饰器返回的新函数替换原函数

关键点在于,装饰后,greet 这个名字不再指向原来的 greet 函数,而是指向了 reverse_factory(greet) 返回的 wrapper 函数。


章节4:编写通用装饰器 🌉

我们最初的 reverse_factory 装饰器只能处理接收一个 text 参数的函数。一个健壮的装饰器应该能处理各种不同签名的函数。为此,我们需要使用 *args**kwargs

  • *args 用于接收任意数量的位置参数,并将其打包成一个元组。
  • **kwargs 用于接收任意数量的关键字参数,并将其打包成一个字典。

练习1:在函数调用前后打印信息

以下是编写一个通用装饰器的练习,该装饰器会在被装饰函数执行前后打印信息。

要求:编写装饰器 before_and_after,使其能应用于任何函数,并在函数调用前后打印 “Before” 和 “After”。

def before_and_after(func):
    def wrapper(*args, **kwargs):
        print("Before")
        # 调用原函数并保存其返回值
        result = func(*args, **kwargs)
        print("After")
        # 返回原函数的返回值
        return result
    return wrapper

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_63.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_65.png)

@before_and_after
def greet(name):
    print(f"Hi {name}")

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_67.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_69.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_70.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_72.png)

greet("World")
# 输出:
# Before
# Hi World
# After

这个 wrapper(*args, **kwargs) 结构是装饰器的标准模式,它确保了装饰器可以通用地处理任何函数。


章节5:处理函数返回值 📦

装饰器不仅可以在函数前后执行代码,还可以修改或处理函数的返回值。让我们通过另一个练习来巩固这个概念。

练习2:运行函数两次

要求:编写装饰器 do_twice,它会使被装饰的函数运行两次,并将两次的结果作为一个元组返回。

import random

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_105.png)

def do_twice(func):
    def wrapper(*args, **kwargs):
        # 调用原函数两次
        first_result = func(*args, **kwargs)
        second_result = func(*args, **kwargs)
        # 将两次结果作为元组返回
        return (first_result, second_result)
    return wrapper

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_107.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_109.png)

@do_twice
def roll_dice():
    return random.randint(1, 6)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_111.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/pycon-2021/img/bea56f8bc14b9ea3760a592a2d1f384c_113.png)

print(roll_dice())  # 输出类似: (3, 5)

这个例子展示了装饰器如何介入函数的执行流程,并操作其返回结果。


总结

在本节课中,我们一起学习了Python装饰器的核心知识:

  1. 装饰器是什么:装饰器是一个接收函数作为参数、并返回一个新函数的函数,用于增强或修改原函数的行为。
  2. 核心概念:理解“函数是一等公民”是理解装饰器的基础,包括函数赋值、传参和返回。
  3. 装饰器语法@decorator_namefunc = decorator_name(func) 的简洁写法。
  4. 通用装饰器结构:使用 def wrapper(*args, **kwargs): 来创建能处理任意参数的装饰器,并确保正确传递参数和返回值。
  5. 装饰器的能力:装饰器可以在函数调用前后执行代码,修改传入参数,处理或替换返回值。

通过掌握装饰器,你可以写出更模块化、更可复用和更易读的Python代码。

posted @ 2026-03-29 09:24  布客飞龙II  阅读(12)  评论(0)    收藏  举报