Twisted 兼容性策略

动机

Twisted 项目的开发团队规模很小,我们无法为 Twisted 的多个版本分支提供除关键错误修复支持之外的任何支持。但是,我们都希望 Twisted 在开发、部署和使用过程中提供积极的体验。因此,我们需要提供尽可能无忧无虑的升级流程,这样 Twisted 应用程序开发者就不会回避包含必要错误修复和功能增强的升级。

Twisted 被各种各样的应用程序使用,其中许多是专有的或 Twisted 开发团队无法访问的。这些应用程序中的每一个都是针对特定版本的 Twisted 开发的。最重要的是要保持 Python API 级的兼容性。Python 没有为我们提供严格的方法来划分**公共**和**私有**对象(方法、类、模块),因此不幸的是,许多应用程序很可能正在使用 Twisted 的任意部分。我们的兼容性策略需要考虑到这一点,并且在我们整个代码库中都是全面的。

对于被积极标记为**不稳定**或**实验性**的模块,可以做出例外,但即使是实验性模块,如果存在的时间足够长,也会开始在生产代码中使用。

本文档的目的是为希望在 Twisted 升级时应对更改的 Twisted 应用程序开发者以及希望进行可能与 Twisted 本身不兼容的更改的 Twisted 引擎开发者(包括贡献者和核心团队成员)制定规则。

定义兼容性

“兼容性”这个词本身很难定义。虽然全面兼容性很好,但完全兼容性既不可行也不可取。完全兼容性要求任何东西都不变,因为对 Python 代码的任何更改都可以被一个足够确定的程序检测到。关于哪些类型的更改**显然**不会破坏其他程序,有一些民间知识,但这些知识零散且不一致。与其试图禁止特定类型的更改,不如列出一些被认为是兼容的更改。

在本文档中,**兼容**更改是指满足这些特定标准的更改。虽然更改可能被广泛认为是向后兼容的,但只要它不符合此官方标准,它将被正式视为**不兼容**,并通过不兼容更改的流程。

这里描述的兼容性策略 99% 是关于对**接口**的更改,而不是对功能的更改。

注意

最终,我们希望让用户满意,但我们不能将所有可能让所有用户满意的内容都放入此策略中。

开发者简要说明

以下是弃用代码需要做的事情的摘要。这不是一个详尽的阅读,除了这个列表之外,您应该继续阅读本文档的其余部分

  • 不要在弃用过程中更改函数的行为。

  • 导致导入或使用类/函数/方法发出 DeprecationWarning,要么调用 warnings.warn,要么使用其中一个辅助 API

  • 警告文本必须包含函数首次被弃用的 Twisted 版本(这将始终是将来的版本)

  • 警告文本应建议一个替代方案,如果存在的话。

  • 警告必须“指向”调用该函数的代码。例如,在正常情况下,这意味着将 stacklevel=2 传递给 warnings.warn。

  • 必须有一个单元测试来验证弃用警告。

  • 必须添加一个 .removal 新闻片段来宣布弃用。

不兼容更改的流程

任何在下一节中专门描述为**兼容**的更改都可以在任何时间、任何版本中进行。

第一个总是免费的

本文档的总体目的是为 Twisted 应用程序开发者和用户提供愉快的升级体验。

此流程的具体目的是通过确保任何在没有警告的情况下运行的应用程序都可以升级一个 Twisted 次要版本(在 x.y.z 中从 y 到 y+1)或从主要版本的最后一个次要修订版升级到下一个主要版本的第一个次要修订版(在 x.y.z 到 x.0.z 中从 x 到 x + 1,此时将没有 x.y+1.z)来实现这种体验。

换句话说,任何在没有触发 Twisted 任何警告的情况下运行其测试的应用程序都应该能够至少升级一次 Twisted 版本,而不会产生任何不良影响,除了可能产生新的警告。

不兼容更改

任何未明确描述为**兼容**的更改都必须分两个阶段进行。如果在版本 R 中进行了更改,则时间线为

  1. 版本 R:添加新功能并使用 DeprecationWarning 弃用旧功能。

  2. 最早在版本 R+2 和版本 R 之后一年,但通常要晚得多:完全删除旧功能。

当弃用的 API 成为额外的维护负担时,应该进行删除。

例如,如果它使新功能的实现变得更加困难,如果它使非弃用 API 的文档变得更加混乱,或者如果它的单元测试对持续集成系统造成了过大的负担。

删除不应仅仅是为了遵循时间线而进行。Twisted 应该尽可能地努力,不要破坏依赖它的应用程序。

此策略例外情况的流程

每个更改都是独一无二的。

有时,我们可能希望进行符合本文档精神(让 Twisted 继续为依赖它的应用程序工作)但可能不符合上述流程的文字(更改修改了现有 API 的行为,足以导致某些东西被破坏)。通常,人们想要这样做是因为要为应用程序提供性能增强或错误修复,这些增强或修复可能会破坏现有 API 的意外假设使用中的行为,但我们不希望行为良好的应用程序为了获得改进的好处而支付弃用/采用新 API/删除循环的代价,如果他们不需要的话。

如果您的更改是这种情况,则可以在没有弃用/删除循环的情况下进行此类修改。但是,我们必须让用户有机会发现特定不兼容更改是否影响他们:我们不应该相信自己对代码如何使用 API 的评估。为了提出不兼容更改,请在邮件列表中开始讨论。确保它引人注目,以便那些没有深入阅读所有列表消息的人会注意到它,方法是在主题前加上**INCOMPATIBLE CHANGE:**(像这样大写)。始终包含指向工单和分支(如果相关)的链接。

为了**结束**此类讨论,必须有一个分支可用,以便开发者可以针对它运行他们的单元测试,以机械地验证他们对他们自己代码的理解是否正确。如果在该分支 1. 可用且 2. 宣布后,没有人能够在**一周时间内**产生失败的测试或损坏的应用程序,并且至少**三位提交者**同意该更改是值得的,那么该分支可以被认为是批准的,用于所讨论的不兼容更改。

由于使用 Twisted 的一些代码库可能是专有的和机密的,因此如果有人说他们有损坏的测试,但不能立即提供代码来共享,应该有一个善意的推定。

该分支必须可用一周时间。

注意

不兼容更改的公告论坛以及所需的等待时间可能会随着我们发现此方法的有效性而改变;此策略的重要方面是用户能够提前了解可能影响他们的更改。

兼容更改。不受兼容性策略涵盖的更改

以下是不受兼容性策略涵盖的更改的非详尽列表。这些更改可以在不考虑兼容性策略的情况下进行。

测试更改

测试包中的任何代码或数据都不应被 Twisted 中的非测试包导入或使用。这样做可以确保没有任何东西可以通过公共 API 访问这些对象。

测试代码和测试助手被视为私有 API,不应在 Twisted 测试基础设施之外导入。

私有更改

如果用户需要键入一个前导下划线才能访问代码,则该代码被认为是私有的。换句话说,以下划线开头的函数、模块、方法、属性或类可以任意更改。

错误修复和严重违反规范

如果 Twisted 文档中说明某个对象符合已发布的规范,并且存在会导致 Twisted 明显违反该规范的行为的输入,则可以对这些输入进行更改以纠正行为。

如果应用程序代码必须支持多个版本的 Twisted,并解决此类规范的违规问题,则必须在补偿之前测试是否存在此类错误。

例如,Twisted 在 twisted.web.microdom 中提供了一个 DOM 实现。如果发现解析字符串 <xml>Hello</xml> 然后再次序列化它会导致 >xml<Hello>/xml<,这将严重违反 XML 规范的格式良好性。此类代码可以修复,无需任何警告,除了发布说明中详细说明此错误现已修复。

原始源代码

当然,Twisted 版本之间可能发生的 最基本的事情是代码可能会更改。这意味着任何应用程序都可能永远不能依赖于,例如,任何 func_code 对象的 co_code 属性的值保持稳定,或者 .py 文件的 checksum 保持稳定。

Docstrings 也可能随时更改。应用程序不能依赖于任何 Twisted 类、模块或方法的元数据属性,例如 __module____name____qualname____annotations____doc__ 保持不变。

新属性

也可以添加新代码。应用程序不能依赖于任何对象上 dir() 函数的输出保持稳定,也不能依赖于任何对象的 __all__ 属性,也不能依赖于任何对象的 __dict__ 没有添加新的键。这些可能会发生在任何维护或错误修复版本中,无论多么微不足道。

序列化

即使 Python 对象可以在没有显式支持的情况下进行序列化和反序列化,但特定序列化对象在对该对象的实现进行任何特定更改后是否可以反序列化,这一点尚不确定。因此,应用程序不能依赖于 Twisted 定义的任何对象在任何版本之间提供序列化兼容性,除非该对象明确将此作为其具有的功能进行记录。

表示

对象的打印表示形式,如 repr(<object>) 返回并由 def __repr__(self): 定义,用于调试和信息目的。因此,应用程序不能依赖于 Twisted 定义的任何对象在任何版本之间提供 repr 兼容性。属性访问 ^^^^^^^^^^^^^^^^ 对象的属性如何定义和访问被认为是实现细节。为了允许向后兼容性,属性可以从实例 __dict__ 移动到 @property 或其他基于描述符的访问器。

向已构造的对象添加新属性或进行猴子补丁,不被视为公共使用。此限制允许创建和转换为插槽类。因此,应用程序不能依赖于 Twisted 定义的任何对象在任何版本之间提供 __dict____slots__ 兼容性。

兼容性策略涵盖的更改

以下是不受兼容性策略涵盖的更改的非详尽列表。

一些更改似乎符合上述描述什么是兼容的规则,但实际上并非如此。

接口更改

虽然可以在实现中添加方法,但将这些方法添加到接口可能会在用户代码中引入意外要求。

注意

目前,在 zope.interface 中无法表达接口可以选择提供某些需要测试的功能。虽然我们可以添加新代码,但我们无法添加对用户代码的新要求来实现新方法。

在使用抽象基类的系统中,这更容易处理,因为新要求可以提供提供警告的默认实现。也可以在接口中执行类似的操作,因为它们已经安装了元类,但这很棘手。我所知道的唯一例子是 Microsoft 的 ISomeInterfaceN 传统,其中 N 是每个版本的单调递增数字。

通过公共入口点提供的私有对象

如果一个公共入口点返回一个私有对象,那么该私有对象必须保留其公共属性。

在以下示例中,_ProtectedClass 不能再任意更改。具体来说,getUsers() 现在是一个公共方法,这要归功于 get_users_database() 公开了它。但是,_checkPassword() 仍然可以任意更改或删除。

例如

class _ProtectedClass:
    """
    A private class which is initialized only by an entry point.
    """
    def getUsers(self):
        """
        A public method covered by the compatibility policy.
        """
        return []

    def _checkPassword(self):
        """
        A private method not covered by the compatibility policy.
        """
        return False



def get_users_database():
    """
    A method guarding the initialization of the private class.

    Since the method is public and it returns an instance of the
    C{_ProtectedClass}, this makes the _ProtectedClass a public class.
    """
    return _ProtectedClass()

私有类被公共子类继承

以任何方式被公共子类继承或公开的私有类将使继承的类成为公共类。

私有仍然受到直接实例化的保护。

class _Base(object):
    """
    A class which should not be directly instantiated.
    """
    def getActiveUsers(self):
        return []

    def getExpiredusers(self):
        return []

class Users(_Base):
    """
    Public class inheriting from a private class.
    """
    pass

在以下示例中,_Base 实际上是公共的,因为 getActiveUsers()getExpiredusers() 都通过公共 Users 类公开。

记录和测试的严重违反规范

如果后来发现的错误的行为被记录下来,或者修复它导致现有测试失败,那么无论其违反程度如何,更改都应被视为不兼容。可能是这些违规行为是专门引入的,以处理该规范的其他严重不符合规范的实现。如果确定这些原因无效或应该通过不同的 API 公开,则更改是兼容的。

应用程序开发人员升级过程

当应用程序想要升级到新版本的 Twisted 时,它可以立即进行。

但是,如果应用程序想要在下次升级时免费获得相同行为,则应用程序的测试应运行,将警告视为错误,并进行修复。

支持和不支持 Python 版本

Twisted 没有关于支持新版本的 Python 或不支持旧版本的 Python 的正式策略。我们努力在任何版本的 Python 上支持 Twisted,这些版本是主要平台(即 Debian、Ubuntu、最新版本的 Windows 或最新版本的 macOS)的供应商支持版本中的默认 Python。当前支持的 Python 版本列在每个版本的 ​INSTALL 文件中。

只有当存在 buildbot 构建器 时,发行版 + Python 版本才被视为受支持。

删除对 Python 版本的支持将在删除之前至少提前 1 个版本宣布。

如何弃用 API

当从其模块内部访问类时,通过引发警告来弃用类,使用 deprecatedModuleAttribute 助手。

class SSLContextFactory:
    """
    An SSL context factory.
    """
    deprecatedModuleAttribute(
        Version("Twisted", 12, 2, 0),
        "Use twisted.internet.ssl.DefaultOpenSSLContextFactory instead.",
        "twisted.mail.protocols", "SSLContextFactory")

函数和方法

为了弃用某个函数或方法,请在该方法实现的开头添加对 `warnings.warn` 的调用。警告类型应为 `DeprecationWarning`,并且应设置堆栈级别,以便警告指向调用弃用函数或方法的代码。弃用消息必须包含弃用函数的名称、Twisted 中首次弃用该函数的版本以及替换建议。如果 API 提供的功能被认定超出 Twisted 的范围,或者没有替换,则可以将其弃用,但无需提供替换。

还有一个 `deprecated` 装饰器,适用于新式类。

例如

import warnings

from twisted.python.deprecate import deprecated
from twisted.python.versions import Version


@deprecated(Version("Twisted", 1, 2, 0), "twisted.baz")
def some_function(bar):
    """
    Function deprecated using a decorator.
    """
    return bar * 3



@deprecated(Version("Twisted", 1, 2, 0))
def some_function(bar):
    """
    Function deprecated using a decorator and which has no replacement.
    """
    return bar * 3



def some_function(bar):
    """
    Function with a direct call to warnings.
    """
    warnings.warn(
        'some_function is deprecated since Twisted 1.2.0. '
        'Use twisted.baz instead.',
        category=DeprecationWarning,
        stacklevel=2)
    return bar * 3

实例属性

为了弃用新式类实例上的属性,请将该属性转换为属性,并从该属性的 getter 和/或 setter 函数中调用 `warnings.warn`。您也可以使用 `deprecatedProperty` 装饰器,适用于新式类。

from twisted.python.deprecate import deprecated
from twisted.python.versions import Version


class SomeThing(object):
    """
    A class for which the C{user} ivar is not yet deprecated.
    """

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



class SomeThingWithDeprecation(object):
    """
    A class for which the C{user} ivar is now deprecated.
    """

    def __init__(self, user=None):
        self._user = user


    @deprecatedProperty(Version("Twisted", 1, 2, 0))
    def user(self):
        return self._user


    @user.setter
    def user(self, value):
        self._user = value

模块属性

模块不能拥有属性,因此应使用 `deprecatedModuleAttribute` 辅助函数来弃用模块属性。

from twisted.python import _textattributes
from twisted.python.deprecate import deprecatedModuleAttribute
from twisted.python.versions import Version

flatten = _textattributes.flatten

deprecatedModuleAttribute(
    Version('Twisted', 13, 1, 0),
    'Use twisted.conch.insults.text.assembleFormattedText instead.',
    'twisted.conch.insults.text',
    'flatten')

模块

为了弃用整个模块,可以使用 `deprecatedModuleAttribute` 在父包的 `__init__.py` 上。

还有其他两种选择

  • 在模块的顶层代码中添加 `warnings.warn()` 调用。

  • 弃用模块的所有属性。

测试弃用代码

与 Twisted 中的所有更改一样,弃用必须附带相关的自动化测试。

由于 Trial 中的一个错误(#6348),未处理的弃用警告不会导致测试失败或显示在测试结果中。

在修复 Trial 错误之前,要触发未处理的弃用警告的测试失败,请使用

python -Werror::DeprecationWarning ./bin/trial twisted.conch

有几种方法可以检查代码是否已弃用,以及使用它是否会引发 `DeprecationWarning`。

提供了一些辅助方法来处理弃用的可调用对象(callDeprecated)和弃用的类或模块属性(getDeprecatedModuleAttribute)。

如果弃用警告具有自定义消息,或者无法使用这些辅助方法捕获,则可以使用 `assertWarns` 来指定您期望的具体警告。

最后,您可以在执行任何弃用操作后使用 `flushWarnings`。这是最精确的方法,但也最冗长,用于断言您已引发 `DeprecationWarning`。

from twisted.trial import unittest


class DeprecationTests(unittest.TestCase):
    """
    Tests for deprecated code.
    """
    def test_deprecationUsingFlushWarnings(self):
        """
        flushWarnings() is the recommended way of checking for deprecations.
        Make sure you only flushWarning from the targeted code, and not all
        warnings.
        """
        db.getUser('some-user')

        message = (
            'twisted.Identity.getUser was deprecated in Twisted 15.0.0: '
            'Use twisted.get_user instead.'
            )
        warnings = self.flushWarnings(
            [self.test_deprecationUsingFlushWarnings])
        self.assertEqual(1, len(warnings))
        self.assertEqual(DeprecationWarning, warnings[0]['category'])
        self.assertEqual(message, warnings[0]['message'])


    def test_deprecationUsingCallDeprecated(self):
        """
        callDeprecated() assumes that the DeprecationWarning message
        follows Twisted's standard format.
        """
        self.callDeprecated(
            Version("Twisted", 1, 2, 0), db.getUser, 'some-user')


    def test_deprecationUsingAssertWarns(self):
        """
        assertWarns() is designed as a general helper to check any
        type of warnings and can be used for DeprecationsWarnings.
        """
        self.assertWarns(
            DeprecationWarning,
            'twisted.Identity.getUser was deprecated in Twisted 15.0.0 '
            'Use twisted.get_user instead.',
            __file__,
            db.getUser, 'some-user')

当代码被弃用时,所有以前调用和测试该代码的测试现在都会引发 `DeprecationWarning`。可以使用 `callDeprecated` 辅助函数来调用弃用代码而不会引发这些警告。

from twisted.trial import unittest


class IdentityTests(unittest.TestCase):
    """
    Tests for our Identity behavior.
    """

    def test_getUserHomePath(self):
        """
        This is a test in which we check the returned value of C{getUser}
        but we also explicitly handle the deprecations warnings emitted
        during its execution.
        """
        user = self.callDeprecated(
            Version("Twisted", 1, 2, 0), db.getUser, 'some-user')

        self.assertEqual('some-value', user.homePath)

需要使用弃用类的测试应使用 `getDeprecatedModuleAttribute` 辅助函数。

from twisted.trial import unittest


class UsernameHashedPasswordTests(unittest.TestCase):
    """
    Tests for L{UsernameHashedPassword}.
    """
    def test_initialisation(self):
        """
        The initialisation of L{UsernameHashedPassword} will set C{username}
        and C{hashed} on it.
        """
        UsernameHashedPassword = self.getDeprecatedModuleAttribute(
            'twisted.cred.credentials', 'UsernameHashedPassword', Version('Twisted', 20, 3, 0))
        creds = UsernameHashedPassword(b"foo", b"bar")
        self.assertEqual(creds.username, b"foo")
        self.assertEqual(creds.hashed, b"bar")