Python3-高级编程-四-

47 阅读1小时+

Python3 高级编程(四)

原文:Pro Python 3

协议:CC BY-NC-SA 4.0

九、测试

编写应用只是过程的一部分;检查所有代码是否正常工作也很重要。您可以直观地检查代码,但是最好在现实世界中可能出现的各种情况下执行它,以确保它正常运行。这个过程被称为单元测试,因为目标是测试最小的可用执行单元。

通常,最小的单元是一个函数或方法,许多这样的单元组合起来形成一个完整的应用。通过将它分解成单独的单元,您可以最小化每个测试所负责的工作量。这样,任何特定单元的故障都不会涉及数百行代码,因此更容易准确地跟踪出了什么问题。

对于大型应用来说,测试每个单独的单元可能是一个漫长的过程,因为您可能需要考虑许多场景。您可以通过让您的代码完成繁重的工作来自动化这个过程,而不是试图手动完成所有的工作。编写测试套件允许您轻松地尝试代码可能采用的所有不同路径,验证每条路径的行为是否正常。

测试驱动开发

自动化测试的一个更极端的例子是测试驱动开发的实践,通常简称为 TDD。顾名思义,这种实践使用自动化测试来驱动开发过程。每当编写一个新特性时,首先编写该特性的测试——这些测试会立即失败。一旦测试就绪,您将编写代码来确保这些测试通过。

这种方法的一个价值是,它鼓励您在开始编写代码之前更彻底地理解期望的行为。例如,一个处理文本的函数可能有许多公共输入字符串,每个字符串都有一个期望的输出。首先编写测试鼓励您考虑每个可用输入字符串的输出字符串,而不考虑字符串在内部是如何处理的。通过从一开始就转移对代码的关注,更容易看到全局。关注接口(名称、函数、方法签名等)的好处。)不要低估早期,因为这里的更改比以后的实现更改更难。

然而,更明显的优点是,它确保了应用中的每一段代码都有一组与之相关的测试。当代码排在第一位时,手动运行几个基本场景,然后继续编写下一个特性就太容易了。测试可能会在混乱中丢失,即使它们对于项目的长期健康发展是必不可少的。养成先写测试的习惯是确保它们被写出来的好方法。

不幸的是,许多开发人员发现测试驱动的开发对于实际工作来说过于严格。然而,只要尽可能全面地编写测试,您的代码就会受益。最简单的方法之一是编写文档测试。

文档测试

文档主题在第八章中有所涉及,但是它的一个特殊方面对测试很有用。因为 Python 支持可以由代码而不仅仅是人来处理的文档字符串,所以这些字符串中的内容也可以用于执行基本的测试。

为了与常规文档一起发挥双重作用,文档测试必须看起来像文档,同时仍然可以被解析、执行和验证正确性。有一种格式非常适合这个要求,并且在本书中一直使用。文档测试被格式化为交互式解释器会话,其中已经包含了易于识别的输入和输出格式。

格式化代码

尽管 doctest 的整体格式与本书中显示的解释器会话完全相同,但是有一些特定的细节需要识别。要执行的每一行代码都以三个右尖括号(>>>)和一个空格开始,后面是代码本身:

img/330715_3_En_9_Figa_HTML.jpg

>>> a = 2

就像交互式解释器一样,任何超出一行的代码都由以三个句点(...)而不是括号开头的新行来表示。为了完成多行结构,如列表和字典,以及函数和类定义,您可以根据需要包含尽可能多的这些内容:

img/330715_3_En_9_Figb_HTML.jpg

>>> b = ('example',

... 'value')
>>> def test():
...     return b * a

所有像这样以句点开头的行都与以尖括号开头的最后一行组合在一起,它们都被一起求值。这意味着如果有必要,你可以在结构中的任何地方,甚至在结构之后,留下额外的行。这对于模拟实际解释器会话的输出很有用,它需要一个空行来指示缩进结构(如函数或类)何时完成:

img/330715_3_En_9_Figc_HTML.jpg

>>> b = ('example',

...
... 'value')
>>> def test():
...     return b * a
...

表示输出

代码就绪后,我们只需要验证它的输出是否与预期相符。为了与解释器格式保持一致,输出显示在一行或多行输入代码的下面。输出的确切格式取决于正在执行的代码,但这与您在解释器中直接输入代码时看到的是一样的:

img/330715_3_En_9_Figd_HTML.jpg

>>> a

2
>>> b
('example', 'value')
>>> test()
('example', 'value', 'example', 'value')

在这些例子中,输出字符串相当于将表达式的返回值传递给内置的repr()函数。因此,字符串将总是被引用,并且许多特定类型将具有与直接打印它们不同的格式。测试str()的输出可以简单地通过在代码行中调用str()来实现。或者,也支持print()功能,其工作方式与您预期的一样:

img/330715_3_En_9_Fige_HTML.jpg

>>> for value in test():

...     print(value)
example
value
example
value

在像这样的例子中,输出的所有行都根据提供的代码实际返回或打印的内容进行检查。这提供了一种可读性很强的处理序列的方法,如下所示。对于更长的序列,以及允许输出从一个运行改变到另一个运行的情况,输出也可以包括三个省略号,指示应该忽略附加内容的位置:

img/330715_3_En_9_Figf_HTML.jpg

>>> for value in test():

...     print(value)
example
...
value

这种形式在测试异常时特别有用:解释器输出包括文件路径,这些路径几乎总是会从一个系统到另一个系统发生变化,并且与大多数测试无关。在这些情况下,重要的是测试是否引发了异常,异常的类型是否正确,以及异常的值(如果有)是否正确:

img/330715_3_En_9_Figg_HTML.jpg

>>> for value in test:

...     print(value)
Traceback (most recent call last):
  ...
TypeError: 'function' object is not iterable

正如这里的输出格式所示,doctest 将验证异常输出的第一行和最后一行,而忽略中间的整个回溯。因为追溯细节通常也与文档无关,所以这种格式可读性更好。

与文档集成

因为测试应该被构建到文档中,所以需要有一种方法来确保只执行测试。为了在不中断文档流的情况下区分这两者,测试仅仅通过额外的换行符而被搁置。您必须始终使用一个换行符来避免它们都出现在一行中,所以添加一个额外的换行符只会在两者之间留下一个空行:

img/330715_3_En_9_Figh_HTML.jpg

"""

This is an example of placing documentation alongside tests in a single string.

Additional documentation can be placed between snippets of code, and it won't

disturb the behavior or validity of the tests.

"""

print("Hello, world!")

运行测试

文档测试的实际执行由doctest模块提供。在最简单的形式中,您可以运行单个函数来测试整个模块。这在为已经编写好的文件编写一组测试时非常有用,因为您可以在编写新的测试后轻松地单独测试文件。只需导入doctest并运行它的testmod()函数来测试模块。下面是一个包含几种类型的文档测试的示例模块:

img/330715_3_En_9_Figi_HTML.jpg

def times2(value):

    """
    Multiplies the provided value by two. Because input objects can override
    the behavior of multiplication, the result can be different depending on
    the type of object passed in.

    >>> times2(5)
    10
    >>> times2('test')
    'testtest'
    >>> times2(('a', 1))
    ('a', 1, 'a', 1)
    """
    return value * 2

if __name__ == '__main__':
    import doctest
    doctest.testmod()

times2()函数中的 docstring 包含测试,因为它是模块级函数,所以testmod()可以看到它并执行测试。这个简单的构造允许您直接从命令行调用模块,并查看模块中所有文档测试的结果。例如,如果这个模块叫做times2.py,您可以从命令行调用它,如下所示:

img/330715_3_En_9_Figj_HTML.jpg

$ python times2.py

$

默认情况下,输出只包含错误和失败,所以如果所有测试都通过了,就不会有任何输出。失败在单个测试中报告,每个输入/输出组合被认为是一个独特的测试。这提供了关于所尝试的测试的性质以及它们如何失败的细粒度细节。如果示例 doctest 中的最后一行只显示('a', 1),将会发生以下情况:

img/330715_3_En_9_Figk_HTML.jpg

$ python times2.py

**********************************************************************
File "...", line 11, in __main__.times2
Failed example:
    times2((a, '1'))
Expected:
    (a, '1')
Got:
    (a, '1', a, '1')
**********************************************************************
1 items had failures:
   1 of   3 in __main__.times2
***Test Failed*** 1 failures.
$

然而,当处理更复杂的应用和框架时,doctests 的简单输入/输出范例很快就会崩溃。在这些情况下,Python 中有两个很好的测试:Pytest 和unittest模块。作为对 doctests 的一种替代,我们接下来将研究 unittest。

单元测试模块

与要求您的测试以非常特殊的方式格式化的 doctests 不同,unittest允许您用真正的 Python 代码编写测试,从而提供了更大的灵活性。通常情况下,这种额外的能力需要对如何定义测试进行更多的控制。在单元测试的情况下,这种控制是通过面向对象的 API 提供的,用于定义单独的测试、测试套件和测试使用的数据设备。

导入unittest模块后,首先要开始的是TestCase类,它构成了模块大部分特性的基础。你还应该检查一下 pytest。org ,但这个类应该首先考虑。unittest 模块本身并不做太多事情,但是当它被子类化时,它提供了一组丰富的工具来帮助定义和控制你的测试。这些工具是您可以用来执行单独测试的现有方法和您可以定义来控制测试工作方式的新方法的组合。这一切都从创建TestCase类的子类开始:

img/330715_3_En_9_Figl_HTML.jpg

import unittest

class MultiplicationTestCase(unittest.TestCase):
    pass

安装

大多数测试用例的起点是setUp()方法,您可以定义它在所有将在类上定义的测试开始时执行一些任务。常见的设置任务包括定义稍后将进行比较的静态值、打开数据库连接、打开文件以及加载数据进行分析。

这个方法没有参数,也不返回任何东西。如果您需要用任何参数来控制它的行为,您将需要以一种方式来定义这些参数,使得setUp()可以访问它们,而不用将它们作为参数传入。一种常见的技术是检查os.environ中影响测试行为的特定值。另一个选择是拥有可定制的设置模块,这些模块可以在setUp()中导入,然后可以修改测试行为。

同样地,setUp()为以后使用而定义的任何值都不能使用标准值返回。相反,它们可以存储在TestCase对象本身上,该对象将在运行setUp()之前被实例化。下一节将展示单独的测试被定义为同一个对象上的方法,因此在设置期间存储的任何属性在测试执行时都可供测试使用:

img/330715_3_En_9_Figm_HTML.jpg

import unittest

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

注意

如果你看 PEP 8(Python 代码的风格指南),你会注意到名字setUp()没有遵循标准的 Python 命名约定。这里的大写形式基于 Java 测试框架 JUnit。Python 的单元测试系统是从 Java 移植过来的,并且它的一些风格也延续了下来。一定要检查这个 PEP,因为它提供了一些关于代码可读性的非常重要的信息。

写作测试

设置就绪后,您可以编写一些测试来验证您正在处理的任何行为。像setUp()一样,这些是作为你的测试用例类上的定制方法实现的。然而,与setUp()不同,没有一个特定的方法必须实现所有的测试。相反,测试框架会在你的测试用例类中寻找名字以单词test开头的方法。

对于它找到的每个方法,测试框架在执行测试方法之前执行setUp()。这有助于确保每个方法都依赖于一致的环境,而不管有多少方法,它们各自做什么,或者它们执行的顺序如何。完全确保一致性还需要一个步骤,但这将在下一节中讨论。

当编写测试方法的主体时,TestCase类提供了一些实用方法来描述你的代码应该如何工作。这些都是以这样一种方式设计的,即每一个都代表一个必须为真才能继续的条件。有几种这样的方法,每一种都覆盖一种特定类型的断言。如果给定的断言通过,测试将继续到下一行代码;否则,测试会立即停止,并生成一条失败消息。每种方法都提供了在失败时使用的默认消息,但也接受一个参数来自定义该消息:

  • assertTrue(expr, msg=None):该方法测试给定表达式的计算结果是否为True。这是最简单的断言,反映了内置的assert关键字。然而,使用这种方法会将失败绑定到测试框架中,所以应该使用它。如果你喜欢使用assert关键字,这种方法也可以作为assert_()使用。

  • assertFalse(expr, msg=None):与assertTrue()相反,只有当提供的表达式计算结果为False时,该测试才会通过。

  • fail(msg=None):该方法显式生成失败消息。如果失败的条件比内置方法本身提供的条件更复杂,这是有用的。生成失败比引发异常更可取,因为它表明代码以测试可以理解的方式失败,而不是未知的方式。

这些函数本身就为您的其余测试提供了一个基本的调色板。要开始将早期的 doctest 转换为单元测试,我们可以通过提供一个testNumber()方法来模拟之前执行的第一个测试。像 doctests 一样,unittest模块也提供了一个简单的函数来运行在给定模块中找到的所有测试;这一次,它叫做main():

img/330715_3_En_9_Fign_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertTrue(times2.times2(5) == 10)

if __name__ == '__main__':

    unittest.main()

测试通常存储在一个名为tests.py的模块中。保存该文件后,我们可以像前面显示的 doctest 示例一样执行它:

img/330715_3_En_9_Figo_HTML.jpg

$ python tests.py

.
----------------------------------------------------------------------
Ran 1 test in 0.001s

与 doctests 不同,默认情况下,单元测试会显示一些统计数据。每个周期代表运行的一个测试,所以具有几十、几百甚至几千个测试的复杂应用可以很容易地用结果填满几个屏幕。这里还显示了故障和错误,使用E表示错误,使用F表示故障。此外,每次失败都会产生一个文本块来描述哪里出错了。看看当我们改变测试表达式时会发生什么:

img/330715_3_En_9_Figp_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertTrue(times2.times2(5) == 42)

if __name__ == '__main__':
    unittest.main()

img/330715_3_En_9_Figq_HTML.jpg

$ python tests.py

假设您在同一个终端会话中,并且键入了前面的函数,运行此代码的输出将是:

F
======================================================================
FAIL: testNumber (__main__.MultiplicationTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 9, in testNumber
    self.assertTrue(times2(5) == 42)
AssertionError: False is not True
----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

正如您所看到的,它准确地显示了哪个测试方法产生了失败,并通过回溯来帮助跟踪导致失败的代码流。此外,故障本身显示为一个AssertionError,并清楚地显示了断言。

然而,在这种情况下,失败消息并没有发挥应有的作用。它报告的只是False不是True。当然,这是一份正确的报告,但它并没有真正讲述事情的全部。为了更好地跟踪哪里出错了,知道函数实际返回了什么是有用的。

为了提供更多关于所涉及的值的信息,我们需要使用一个测试方法来分别识别不同的值。如果它们不相等,测试就会像标准断言一样失败,但是失败消息现在可以包含两个不同的值,这样您就可以看到它们的不同之处。这可能是一个有价值的工具,用于确定代码是如何以及在哪里出错的——毕竟,这是测试的全部目的:

  • assertEqual(obj1, obj2, msg=None):利用第五章中显示的比较特性,检查传入的两个对象是否相等。

  • assertNotEqual(obj1, obj2, msg=None):类似于assertEqual(),只是如果两个对象相等,这个方法会失败。

  • assertAlmostEqual(obj1, obj2, *, places=7, msg=None):特别是对于数值,这个方法在检查相等性之前将数值四舍五入到给定的小数位数。这有助于解决舍入误差和浮点运算引起的其他问题。

  • assertNotAlmostEqual(obj1, obj2, *, places=7, msg=None):与前一种方法相反,如果两个数字四舍五入到指定的位数,则测试失败。

有了assertEqual(),我们可以更改testNumber(),以便在断言失败时产生更有用的消息:

img/330715_3_En_9_Figr_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

if __name__ == '__main__':
    unittest.main()
F
======================================================================
FAIL: testNumber (__main__.MultiplicationTests)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "tests.py", line 9, in testNumber
    self.assertEqual(times2(5), 42)
AssertionError: 10 != 42
----------------------------------------------------------------------
Ran 1 test in 0.001s

FAILED (failures=1)

在幕后,assertEqual()做了一些有趣的事情,尽可能的灵活和强大。首先,通过使用==操作符,它可以使用对象本身定义的任何更有效的方法来比较两个对象。第二,可以通过提供自定义比较方法来配置输出的格式。在unittest模块中提供了几个定制方法:

  • assertSetEqual(set1, set2, msg=None):因为无序序列通常被实现为集合,所以这个方法是专门为集合设计的,使用第一个集合的difference()方法来确定两者之间是否有任何项目不同。

  • 这个方法是专门为字典设计的,目的是考虑它们的值和键。

  • assertListEqual(list1, list2, msg=None):类似于assertEqual(),这个方法是专门针对列表的。

  • assertTupleEqual(tuple1, tuple2, msg=None):和assertListEqual()一样,这是一个定制的等式检查,但是这次是为元组定制的。

  • 如果你没有使用列表、元组或者它们的子类,这个方法可以用来在任何作为序列的对象上做同样的工作。

除了这些现成的方法,您可以将自己的方法添加到测试框架中,这样assertEqual()就可以更有效地使用您自己的类型。通过向addTypeEqualityFunc()方法传递一个类型和一个比较函数,您可以注册它,以便稍后与assertEqual()一起使用。

有效地使用addTypeEqualityFunc()可能很棘手,因为它对整个测试用例类都有效,不管里面有多少测试。在setUp()方法中添加等式函数可能很诱人,但是请记住,对于在TestCase类中找到的每个测试方法,setUp()都会被调用一次。如果等式函数将被注册到该类的所有测试中,那么在每个测试之前注册它是没有意义的。

更好的解决方案是将addTypeEqualityFunc()调用添加到测试用例类的__init__()方法中。这还有一个额外的好处,那就是你可以子类化你自己的测试用例类,为其他测试提供一个更合适的基础。这一过程将在本章后面详细解释。

其他比较

除了简单的相等之外,unittest.TestCase还包括一些其他方法,可以用来比较两个值。主要针对数字,这些解决了测试值是小于还是大于预期值的问题:

  • assertGreater(obj1, obj2, msg=None):类似于相等测试,测试第一个对象是否大于第二个对象。像等式一样,如果适用的话,这也委托给两个对象上的方法。

  • assertGreaterEqual(obj1, obj2, msg=None):这就像assertGreater()一样,除了如果两个对象的比较结果相同,测试也通过。

  • assertLess(obj1, obj2, msg=None):如果第一个对象比第二个对象小,则测试通过。

  • assertLessEqual(obj1, obj2, msg=None):像assertLess()一样,这测试第一个对象是否小于第二个对象,但如果两者相等也通过。

测试字符串和其他序列内容

序列提出了一个有趣的挑战,因为它们由多个单独的值组成。序列中的任何值都可能决定给定测试的成功或失败,因此有必要使用工具专门处理它们。首先,有两种为字符串设计的方法,其中简单的等式可能并不总是足够的:

  • assertMultiLineEqual(obj1, obj2, msg=None):这是assertEqual()的一种特殊形式,为多行字符串设计。等式的工作方式类似于任何其他字符串,但是默认的失败消息经过了优化,以显示值之间的差异。

  • assertRegexpMatches(text, regexp, msg=None):测试给定的正则表达式是否与提供的文本匹配。

更一般地说,序列测试需要确保序列中存在某些项目才能通过。只有当整个序列必须相等时,前面显示的等式方法才有效。如果序列中的一些项目很重要,但其余的可能不同,我们将需要使用一些其他方法来验证:

  • assertIn(obj, seq, msg=None):测试对象是否出现在给定的序列中。

  • assertNotIn(obj, seq, msg=None):这与assertIn()类似,除了当对象作为给定序列的一部分存在时它会失败。

  • assertDictContainsSubset(dict1, dict2, msg=None):该方法采用了assertIn()的功能,并将其专门应用于字典。像assertDictEqual()方法一样,这种专门化允许它也考虑值,而不仅仅是键。

  • assertSameElements(seq1, seq2, msg=None):测试两个序列中的所有项目,仅当两个序列中的项目相同时通过。这只测试单个项目的存在,而不是它们在每个序列中的顺序。这也将接受两个字典,但会将其视为任何其他序列,因此它将只查看字典中的键,而不是它们的关联值。

测试异常

到目前为止,所有的测试方法都采用了积极的方法,即测试验证一个成功的结果确实是成功的。然而,验证不成功的结果也同样重要,因为它们仍然需要是可靠的。许多函数在某些情况下会引发异常,单元测试在验证这种行为时同样有用:

  • assertRaises (exception, callable, *args, **kwargs):这个方法不是检查一个特定的值,而是测试一个可调用函数,看它是否引发了一个特定的异常。除了异常类型和要测试的可调用对象之外,它还接受任意数量的位置和关键字参数。这些额外的参数将被传递给所提供的 callable,这样就可以测试多个流。

  • assertRaisesRegexp (exception, regex, callable, *args, **kwargs):这个方法比assertRaises()稍微具体一些,因为它也接受一个正则表达式,这个表达式必须匹配异常的字符串值才能通过。表达式可以作为字符串或编译后的正则表达式对象传入。

在我们的times2例子中,有许多类型的值不能乘以整数。这些情况可以是函数显式行为的一部分,只要它们得到一致的处理。典型的反应是引发一个TypeError,Python 默认就是这样做的。使用assertRaises()方法,我们也可以对此进行测试:

img/330715_3_En_9_Figs_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

    def testInvalidType(self):
        self.assertRaises(TypeError, times2.times2, {})

有些情况有点复杂,这会给测试带来困难。一个常见的例子是重写一个标准运算符的对象。您可以通过名称调用被覆盖的方法,但是简单地使用操作符本身会更具可读性。不幸的是,assertRaises()的标准形式需要一个可调用的,而不仅仅是一个表达式。

为了解决这个问题,这两种方法都可以使用with块作为上下文管理器。在这种形式中,你不需要提供一个可调用的或者参数,而是只传入异常类型和一个正则表达式(如果使用了assertRaisesRegexp())。然后,在with块的主体中,您可以添加必须引发给定异常的代码。这也比标准版本更具可读性,即使在不需要的情况下也是如此:

img/330715_3_En_9_Figt_HTML.jpg

import unittest

import times2

class MultiplicationTestCase(unittest.TestCase):
    def setUp(self):
        self.factor = 2

    def testNumber(self):
        self.assertEqual(times2.times2(5), 42)

    def testInvalidType(self):
        with self.assertRaises(TypeError):
            times2.times2({})

兼容性:3.1/2.7 之前

assertRaises()方法在 Python 2.5 之前就有了,所以它将在目前使用的大多数 Python 版本中可用。然而,正则表达式变体是在 Python 3.1 中添加的,并被移植到 Python 2.7 中。可以使用try / except组合来模拟相同的功能,以直接访问错误消息,其中可以使用正则表达式来验证其字符串值。

尽管with语句和上下文管理器都是在 Python 2.5 中引入的,但是assertRaises()直到版本 3.1 才支持上下文管理协议。因为在那个版本之前assertRaisesRegexp()方法也不存在,所以在早期版本中不支持上下文管理器。为了在没有上下文管理器的情况下达到同样的效果,您需要创建一个新的可调用函数——通常是 lambda 函数——来传递给测试方法。

测试身份

最后一组包含测试对象身份的方法。这些方法不只是检查它们的值是否相等,而是检查两个对象实际上是否相同。该测试的一个常见场景是当您的代码缓存值以供以后使用时。通过测试标识,您可以验证从缓存返回的值是否与最初放入缓存的值相同,而不仅仅是一个等效的副本:

  • assertIs(ob1, obj2, msg=None):这个方法检查两个参数是否都指向同一个对象。测试是使用对象的身份来执行的,因此,如果对象实际上不是同一个对象,则可能比较为相等的对象仍然会失败。

  • assertIsNot(obj1, obj2, msg=None):只有当两个参数指向两个不同的对象时,assertIs()的反转才会通过。即使它们在其他方面是相等的,这个测试也要求它们具有不同的身份。

  • assertIsNone(obj, msg=None):这是assertIs()常见情况的一个简单快捷方式,其中一个对象与内置的None对象进行比较。

  • assertIsNotNone(obj, msg=None):只有当提供的对象不是内置的None对象时,assertIsNone()的反转才会通过。

拆卸

正如setUp()在每个单独的测试执行之前被调用一样,TestCase对象也调用一个tearDown()方法在测试执行之后清除任何初始化的值。这在测试过程中需要在 Python 之外创建和存储信息的测试中经常使用。这种信息的例子有数据库行和临时文件。一旦测试完成,这些信息就不再需要了,所以在测试完成后进行清理是非常有意义的。

通常,一组处理文件的测试将不得不在过程中创建临时文件,以验证它们被正确地访问和修改。这些文件可以在setUp()中创建,在tearDown()中删除,确保每个测试运行时都有一个新的副本。数据库或其他数据结构也可以这样做。

注意

setUp()tearDown()的关键价值在于它们可以为每个单独的测试准备一个干净的环境。如果您需要为所有测试建立一个环境,以便在所有测试完成后共享或恢复一些更改,那么您需要在开始测试过程之前或之后这样做。

提供自定义测试类

因为unittest模块被设计成一个可以被覆盖的类,所以您可以在它上面编写您自己的类,供您的测试使用。这是一个不同于编写测试的过程,因为您为您的测试提供了更多的工具。您可以覆盖任何在TestCase上可用的现有方法,或者添加任何其他对您的代码有用的方法。

扩展TestCase有用性的最常见方式是添加新方法来测试不同于原始类的功能。文件处理框架可能包括额外的方法来测试给定文件的大小,或者关于其内容的一些细节。检索 Web 内容的框架可以包括检查 HTTP 状态代码或在 HTML 文档中查找单个标签的方法。可能性是无穷的。

改变测试行为

当创建一个测试类时,另一个可用的强大技术是改变测试本身执行方式的能力。最显而易见的方法是覆盖现有的断言方法,这可以改变这些测试的执行方式。还有一些其他方法可以改变标准行为,而不需要覆盖断言方法。

这些额外的覆盖可以在定制类的__init__()方法中管理,因为与setUp()不同,__init__()方法对于每个TestCase对象只被调用一次。这对于那些需要影响所有测试,但在测试运行时不会受到任何测试影响的定制来说是很好的。本章前面提到的一个例子是添加自定义相等比较方法的能力,这些方法用addTypeEqualityFunc()方法注册。

您可以对 test 类进行的另一个修改是定义用于识别失败的异常类型。通常情况下,所有测试失败都会在幕后引发一个AssertionError——当一个assert语句失败时使用相同的异常。如果出于某种原因需要更改,比如为了更好地与更高级别的测试框架集成,可以为failureException类属性分配一个新的异常类型。

作为使用failureException属性生成失败的副作用,您可以使用self.failureException显式地引发它来生成测试失败。这本质上与简单地调用self.fail()是一样的,但是在某些情况下,引发一个异常比调用一个方法更具可读性。

令人兴奋的 Python 扩展:Pillow

Pillow 库在处理图像时为 Python 程序员提供了强大的能力。

Pillow(或 PIL) Python 图像库为 Python 程序员处理图像提供了强大的能力。主网站python-pillow.org,提供了大量关于该图书馆的信息,包括图像存档、显示和处理三个主要功能。当然,PIL 图书馆提供的不止这些。

如何安装枕头(PIL)

在具有管理权限的命令提示符下,键入:

pip3 install pillow
(Enter)

现在您已经安装了它(如果 pip3 报告安装成功),让我们尝试一些特性。

图像显示:确定文件大小,类型,并显示它

使用您选择的 JPG 图像,尝试以下操作:

img/330715_3_En_9_Figu_HTML.jpg

#PIL example 1
from __future__ import print_function
from PIL import Image
my_image = Image.open("sleepy_sab.jpg")
#this image:  http://www.jbbrowning.com/user/pages/02.about/sleepy_sab.JPG
#show data about the image
print(my_image.format, ' Image format')
print(my_image.size, ' Image size')
print(my_image.mode, 'Color mode e.g. RGB, etc.')
#Display the image with the default image application
my_image.show()

重要的是要注意,PIL 将自动打开大多数标准的图像类型,没有任何提示,通过代码。

图像处理:裁剪图像的一部分

在本例中,我们将使用之前的 jpg 图像(因此,如果您使用不同的图像,需要调整设置)并显示原始图像,然后裁剪一点并显示新图像。此裁剪函数需要一个具有四个坐标点的元组,0,0 位于左上角:

img/330715_3_En_9_Figv_HTML.jpg

#PIL example 2
from __future__ import print_function
from PIL import Image

my_image = Image.open("sleepy_sab.jpg")

#Display the image with the default image application
my_image.show()

#Crop a portion of the image from the upper left to
#about halfway and display
#(3456, 2304) is the image size
#0,0 is upper left.  Crop wants a tuple so there are (())
region = my_image.crop((0,0,2000,2000))
region.show()

图像处理:更改图像方向

你也可以用两种不同的方式旋转图像(两种方式都一样)。在下一个示例中,我们将把图像旋转 90 度:

img/330715_3_En_9_Figw_HTML.jpg

#PIL example 3
from __future__ import print_function
from PIL import Image
my_image = Image.open("sleepy_sab.jpg")

#Rotate the image 90 degrees
turny=my_image.transpose(Image.ROTATE_90)
turny.show()

图像处理:滤镜

PIL 内置了许多滤镜,如模糊和增强。此外,还有其他用于颜色转换、像素查找等的滤镜。PIL 的主网站有当前版本的更新。要了解它们有多方便,请查看下面的示例,该示例对图像进行了浮雕处理:

img/330715_3_En_9_Figx_HTML.jpg

#PIL example 4
from PIL import Image
from PIL import ImageFilter
my_image = Image.open("sleepy_sab.jpg")

#Emboss the image
emmy=my_image.filter(ImageFilter.EMBOSS)
emmy.show()

如果你使用建议的图像,可怜的萨巴斯蒂安看起来像一块金属艺术品!你还能在 PIL 身上做更多的事情吗?没错。扩展您所学的内容,并尝试使用其他一些过滤器和处理工具。

带着它

本章描述的工具只是功能测试套件的基础。当你写一个应用时,你需要用你的代码应该如何工作的重要方面来填补空白。然而,永远记住,测试不仅仅是为你准备的。通过确保新代码不会破坏现有代码,一旦将代码发布给公众,就可以为用户提供更好的保证。下一章将展示如何让你的代码面向大众。

十、发布

一旦你有了一个可用的应用,下一步就是决定如何以及在哪里分发它。你可能是为自己写的,但最有可能的是你会有更广泛的读者,并有一个发布它的固定时间表。然而,在你这样做之前,有许多决定要做,任务要完成。这一过程主要包括包装和分销,但它始于许可。

批准

在向公众发布任何代码之前,您必须决定管理其使用的许可证。许可证将允许你向你的用户传达你打算如何使用你的代码,你期望其他人如何使用它,你从他们那里要求什么作为回报,以及你期望他们在与你的代码集成后授予他们自己的代码的用户什么权利。这些都是复杂的问题,不可能对每个项目都有一个通用的答案。相反,你需要考虑一些问题。

你自己的哲学起着关键作用,因为它会影响许多其他决定。有些人打算靠他们的代码谋生,这可能意味着源代码根本不会发布。相反,你的工作可以作为一种服务提供,客户可以付费使用。相比之下,你可能对帮助人们更好、更快、更容易或更可靠地学习做事感兴趣。也许最常见的许可证是 GPL。

通用公共许可证

当人们想到开源时,GNU 通用公共许可证(GPL) 1 往往是首先想到的。作为自由软件运动的先锋之一,它的主要目标是保护软件用户的某些自由。GPL 要求,如果你将你的程序分发给其他人,你也必须让他们可以使用该程序的源代码。这样,他们可以自由地对你的代码进行他们认为合适的修改,以便更好地支持他们自己的需求。

此外,GPL 的承诺是,任何修改你的代码的用户只能在 GPL 或至少保证同样自由的许可下发布他们的修改。通过这种方式,软件的用户可以确信,如果它不能让他们满意,他们有办法让它变得更好,不管它离原作者有多远。

因为 GPL 对原始代码和链接到它的代码的任何修改都有要求,所以它有时被称为“病毒性的”这不一定是一种侮辱;它只是指这样一个事实,GPL 对任何使用它的东西都强制使用相同的许可。换句话说,它通过软件传播,就像传统病毒一样。这不是 GPL 独有的特性,但是这是商业世界中很多人在想到 GPL 和开源时首先想到的特性。

因为 GPL 的目标是保护计算机用户的自由,它可以被视为限制程序员的自由。程序员在不泄露源代码的情况下发布应用的自由限制了用户修改代码的自由。在这两种相反的力量中,GPL 通过对程序员的行为设置一些限制来保护用户的自由。

GPL 和 Python

GPL 主要是为静态编译的语言编写的,比如 C 和 C++,所以它经常用“对象形式”的代码来表示,这些代码可能“静态链接”到其他代码。换句话说,当您创建一个 C++可执行文件时,编译器会插入您所引用的库中的代码,以生成一个独立的程序。这些术语是其词汇表的核心,但在应用于 Python 等动态语言时却不太容易理解。许多 Python 应用使用 GPL 是因为它的整体理念,但是它的术语还没有在 Python 应用的环境中经过法庭测试。

看起来这些细节并不重要,因为 Python 代码通常是作为源代码发布的。这里的术语一般都有例外,比如你用 py2exe 做了一个 Windows 编译的 Python 应用。毕竟,编译后的 Python 字节码并不与可能使用该代码的各种系统兼容。但是因为 GPL 也适用于任何其他使用该代码的应用,例如,如果一个静态编译的应用在内部为某些特性使用 GPL Python 代码,这些细节就变得很重要。这种使用是否会触发 GPL 对新应用源代码发布的要求还有待观察。

因为这些限制也必须传递给任何其他包含 GPL 代码的应用,所以可以使用的许可证是有限的。您可能考虑的任何其他许可证必须至少包括与 GPL 相同的限制,尽管如果必要的话可以添加额外的限制。这方面的一个例子是 AGPL。

Affero 通用公共许可证

随着互联网的发展,用户在没有直接获得软件拷贝的情况下与软件进行交互已经变得很普遍。因为 GPL 依赖于代码的分发来触发也分发源代码的要求,所以诸如网站和邮件系统之类的在线服务免除了这一要求。一些人认为,这些豁免利用了 GPL 条款中的漏洞,违反了 GPL 的精神。

为了弥补这个漏洞,Affero 通用公共许可证(AGPL)应运而生。本许可证包含 GPL 的所有限制以及附加功能,即任何与软件交互的用户,即使是通过网络,都将触发分发条款。这样,包含 AGPL 代码的网站必须公开他们所做的任何修改的源代码,以及与它共享公共内部数据结构的任何附加软件。虽然被大众接受有点慢,但是开放源码倡议(OSI)的批准无疑给了这个许可证重要的支持。

注意

尽管 AGPL 的术语和哲学与 GPL 非常相似,但它对 Python 的适用性更清楚一些。因为只要与软件交互就会触发许可条款,所以代码是从静态语言(如 C)编译而来还是从动态语言(如 Python)构建而来并不重要。然而,这也有待于在 Python 案件中进行法庭测试。

因为 AGPL 比 GPL 本身更加严格,所以使用 AGPL 的项目有可能合并最初用标准 GPL 许可的代码。GPL 的所有保护都保持不变,只是增加了一些额外的保护。还有一种限制更少的 GPL 变体,叫做 LGPL。

GNU 宽松通用公共许可证

因为 GPL 声明将一段代码静态链接到另一段代码会触发它的条款,所以许多小的实用程序库的使用频率比它们原本可能的要低。这些库通常不会自己构成一个完整的应用,但是因为它们的有用性需要与宿主应用紧密集成,所以许多开发人员避免使用它们,以避免他们自己的应用也绑定到 GPL。

GNU 宽松通用公共许可证(LGPL)就是通过删除静态链接条款来处理这些情况的。因此,在 LGPL 下发布的库可以在宿主应用中自由使用,而不需要宿主受 LGPL 或任何其他特定许可证的约束。即使是不打算发布任何源代码的专有商业应用也可以包含 LGPL 授权的代码。

但是,所有其他条款保持不变,因此,如果代码本身以任何方式分发,对 LGPL 代码的任何修改都必须作为源代码分发。出于这个原因,许多 LGPL 库都有非常灵活的接口,允许它们的宿主应用有尽可能多的选项,而不必直接修改代码。

本质上,LGPL 更倾向于使用开源的概念来培养一个更加开放的编程社区,而不是保护软件最终用户的权利。沿着这条路走下去是最自由的开源许可之一:BSD。

伯克利软件分发许可证

Berkeley Software Distribution(BSD)许可证提供了一种发布代码的方式,旨在促进尽可能多的采用。它通过对其他方使用、修改和分发代码施加相对较少的限制来做到这一点。事实上,许可证的整个文本仅由几个要点和一个免责声明组成。然而,将 BSD 称为单一许可证是用词不当,因为实际上有一些变体。在最初的形式中,许可证由四点组成:

  • 向程序分发源代码要求代码保留原始版权、许可证文本及其免责声明。

  • 将代码作为编译后的二进制程序分发时,需要将版权、许可文本和免责声明包含在随分发代码提供的文档或其他材料中。

  • 任何用于推广最终产品的广告必须注明 BSD 许可代码包含在产品中。

  • 未经许可本身以外的明确同意,不得使用开发软件的组织的名称或其任何贡献者的名称来专门认可该产品。

请注意,这根本不包含分发源代码的要求,即使是在分发编译后的代码时。相反,它只要求在任何时候都保留适当的归属,并且仍然明确涉及两个不同的当事方。这允许 BSD 许可的代码包含在专有的商业产品中,而不需要发布其背后的源代码,这使得它对大公司相当有吸引力。

然而,广告条款给试图使用 BSD 许可代码的组织带来了一些麻烦。主要问题是,由于代码本身易主,并由不同的组织维护,因此在任何广告材料中必须提到参与开发的每个组织的名称。在某些情况下,这可能是几十个不同的组织,占了广告空间的很大一部分,特别是当软件由于其他原因经常包含相当多的其他免责声明时。

为了解决这些问题,BSD 许可证的另一个版本被创建,没有广告条款。这个许可证被称为新的 BSD 许可证,它包含了原许可证的所有其他要求。广告条款的删除意味着 BSD 授权代码的管理变化对使用它的组织几乎没有影响,这大大扩展了它的吸引力。

BSD 许可证的另一个简化版本叫做简化 BSD 许可证。在这个版本中,甚至删除了非声明条款,只保留了包含许可证文本及其免责声明的要求。为了仍然避免不真实的认可,该版本中的免责声明包括一个额外的句子,该句子明确声明两个群体的观点是相互独立的。

其他许可证

这里列出的选项是一些更常用的选项,但是还有更多可用的选项。OSI 维护着一份开源许可清单【2】,这些许可已经被审查通过,被认为是维护了开源的理念。此外,自由软件基金会维护着它自己的许可证清单 3 这些许可证已经被批准为维护自由软件的理念。

注意

自由软件和开源软件之间的区别主要是哲学上的,但是也有一些现实世界的含义。简而言之,自由软件保留了该软件用户的自由,而开源软件关注的是软件开发模型。并非所有的许可证都被批准用于这两种用途,因此您可能需要决定哪一个对您更重要。

一旦你有了许可证,你就可以开始打包并把你的代码分发给其他可以使用它的人。

包装

单独分发一堆文件并不容易,所以你必须先把它们捆起来。这个过程被称为打包,但是它不应该与标准的 Python 包概念相混淆。传统上,一个包只是一个目录,其中有一个__init__.py文件,这个文件可以用作这个目录中包含的任何模块的名称空间。

出于分发的目的,软件包还包括文档、测试、许可证和安装说明。这些部件的排列方式使得单个部件可以很容易地取出并安装到合适的位置。通常,该结构如下所示:

AppName/
    LICENSE.txt
    README.txt
    MANIFEST.in
    setup.py
    app_name/
        __init__.py
        ...
    docs/
        ...
    tests/
        __init__.py
        ...

如您所见,实际的 Python 代码包是整个应用包的子目录,它与其文档和测试并列。包含在docs目录中的文档可以包含您喜欢的任何形式的文档,但通常是用 reStructuredText 格式化的纯文本文件,如第八章所述。tests目录包含第九章中描述的测试。LICENSE.txt文件包含您选择的许可证的副本,而README.txt提供了对您的应用、其用途和特性的介绍。

这个整体包中更有趣的特性是setup.pyMANIFEST.in,它们不是应用代码的一部分。

setup.py

在您的包中,setup.py是将您的代码实际安装到用户系统中适当位置的脚本。为了尽可能具有可移植性,这个脚本依赖于标准发行版中提供的distutils包。这个包包含一个setup()函数,它使用声明性的方法使这个过程更容易操作,也更通用。

位于distutils.core中的setup()函数接受大量的关键字参数,每个参数描述了包的一个特定特性。有些与整个软件包有关,而有些列出了软件包中包含的单个内容。这些参数中的三个是使用标准工具分发任何包所必需的:

  • name:这个字符串包含了包的公共名称,因为它将显示给那些寻找它的人。命名一个包可能是一项复杂而困难的任务,但是由于它非常主观,所以远远超出了本书的范围。

  • version:这是一个字符串,包含应用的用点分隔的版本号。第一次发布时通常会使用一个版本的'0.1'并从那里开始增加。第一个数字通常是表示兼容性承诺的主要版本。第二个是次要版本号,代表不破坏兼容性的错误修复或重要新功能的集合。第三种通常保留给没有引入新功能或其他错误修复的安全版本。

  • url:这个字符串引用了主网站,用户可以在这里了解更多关于应用的信息,找到更多的文档,请求支持,提交错误报告,或者执行其他任务。它通常充当围绕代码的信息和活动的中心枢纽。

除了这三个必需元素之外,还有几个可选参数可以提供关于应用的更多细节:

  • author:申请作者的姓名。

  • 一个可以直接联系到作者的电子邮件地址。

  • maintainer:如果原作者不再维护该应用,则该字段包含现在负责该应用的人的姓名。

  • maintainer_email:可以直接联系到维护人员的电子邮件地址。

  • description:该字符串提供了程序目的的简要描述。可以把它想象成一个单行的描述,可以和其他描述一起显示在一个列表中。

  • long_description:顾名思义,这是一个更长的应用描述。当用户请求关于特定应用的更多细节时,通常会显示这个选项,而不是在列表中使用。因为这都是在 Python 代码中指定的,所以许多发行版只是将README.txt的内容读入这个参数。

除了这些元数据,setup()函数还负责维护分发应用所需的所有文件的列表,包括所有 Python 模块、文档、测试和许可证。与其他信息一样,这些细节是使用附加的关键字参数提供的。这里列出的所有路径都是相对于setup.py本身所在的主包目录的:

  • license:这是一个文件的名称,它包含了程序分发所依据的许可证的全文。通常这个文件被称为LICENSE.txt,但是通过显式地将它作为一个参数传入,它可以被命名为您喜欢的任何名称。

  • packages:该参数接受实际代码所在的包名列表。与 license 不同,这些值是 Python 导入路径,使用句点沿路径分隔各个包。

  • package_dir:如果你的 Python 包和setup.py不在同一个目录下,这个参数提供了一种方式告诉setup()在哪里可以找到它们。它的值是一个字典,将包名映射到它在文件系统中的位置。您可以使用的一个特殊键是一个空字符串,它将使用相关的值作为根目录来查找任何没有指定显式路径的包。

  • package_data:如果你的包依赖于不是直接用 Python 写的数据文件,那些文件只有在这个参数中被引用时才会被安装。它接受一个将包名映射到其内容的字典,但是与package_dir不同,这个字典中的值是列表,列表中的每个值都是应该包含的文件的路径规范。这些路径可能包含星号,表示要匹配的广泛模式,类似于您可以在命令行上查询的内容。

对于更复杂的配置,还有其他选择,但这些应该涵盖大多数基础。更多信息,请查阅distutils文档。 4 一旦你把这些部分放好,你就会有一个看起来像这样的setup.py:

from distutils.core import setup

setup(name='MyApp',
      version='0.1',
      author='Marty Alchin',
      author_email='marty@propython.com',
      url='http://propython.com/',
      packages=['my_app', 'my_app.utils'],
)

MANIFEST.in

除了指定应该在用户系统上安装什么文件之外,软件包发行版还包括许多对用户有用的文件,而不需要直接安装。这些文件,比如文档,应该对用户可用,但是没有任何代码值,所以它们不应该安装在可执行位置。MANIFEST.in文件控制如何将这些文件添加到包中。

MANIFEST.in是一个纯文本文件,由一系列命令填充,这些命令告诉distutils包中包含哪些文件。这些命令中使用的文件名模式遵循与命令行相同的约定,允许星号作为各种文件名的通配符。例如,一个简单的MANIFEST.in可能包含包的docs目录中的任何文本文件:

include docs/*.txt

这个简单的指令将告诉disutils在 docs 目录中找到所有的文本文件,并将它们包含在最终的包中。通过用空格分隔图案,可以包括附加图案。有几个不同的命令可用,每个命令都有包含和排除版本:

  • include:最明显的选项,这个命令将查找所有匹配任何给定模式的文件,并将它们包含在包中。它们将被放在包中与它们在原始目录结构中相同的位置。

  • exclude:与include相反,它会告诉distutils忽略任何与这里给出的模式匹配的文件。这提供了一种避免包含某些文件的方法,而不必在一个include命令中明确列出每个包含的文件。一个常见的例子是将exclude TODO.txt放在一个专门包含所有文本文件的包中。

  • recursive-include:这个命令需要一个目录作为它的第一个参数,在任何文件名模式之前。然后,它在该目录及其任何子目录中查找匹配给定模式的任何文件。

  • recursive-exclude:和recursive-include一样,这个命令首先获取一个目录,然后是文件名模式。通过此命令找到的任何文件都不会包含在包中,即使它们是通过某个包含命令找到的。

  • global-include:该命令查找项目中的所有路径,不管它们在路径结构中的位置。通过查看目录内部,它的工作方式很像recursive-include,但是因为它查看所有目录,所以除了文件名模式之外,它不需要任何参数。

  • global-exclude:像global-include一样,它在源项目中的任何地方寻找匹配的文件,但是找到的文件被排除在最终的包之外。

  • 这个命令不是寻找匹配的文件,而是接受一组目录,这些目录只是完整地包含在包中。

  • prune:像graft一样,这个命令接受一组目录,但是它将它们完全从包中排除,即使包中有匹配的文件。

有了setup.pyMANIFEST.in之后,distutils提供了一种简单的打包和分发的方法。

sdist 命令

为了最终创建可发布的包,您的新setup.py实际上可以直接从命令行执行。因为这个脚本也用于以后安装软件包,所以您必须指定您希望它执行什么命令。稍后获得包的用户将使用install命令,但是要打包一个源代码发行版,命令是sdist:

$ python setup.py sdist
running sdist
...

该命令处理在setup.py中所做的声明以及来自MANIFEST.in的指令,以创建一个包含您指定要分发的所有文件的归档文件。默认情况下,你得到的存档文件的类型取决于你运行的系统,但是sdist提供了一些你可以明确指定的选项。只需将逗号分隔的格式列表传递给--format选项,即可生成特定的类型:

  • zip:Windows 机器上的默认,这种格式创建一个 zip 文件。

  • 在 Unix 机器上,包括 Mac OS,默认创建一个 gzipped tarball。要在 Windows 系统上创建这个归档文件,您需要安装一个tar的实现,比如可以通过 Cygwin 获得的那个。 5

  • bztar:这个命令在归档 tarball 上使用备用的 bzip 压缩。这也需要安装一个tar的实现。

  • ztar:这个使用更简单的compress算法来压缩 tarball。和其他的一样,使用这个选项需要一个tar的实现。

  • tar:如果有tar实用程序的实现,这个选项不使用压缩,而是简单地打包一个 tarball。

当您运行sdist命令时,您指定的每种格式的归档文件将被创建并放置在您的项目中一个新的dist目录中。每个档案的名称将简单地使用您在setup.py中提供的nameversion,用连字符隔开。前面提供的例子会产生类似于MyApp-0.1.zip的文件。

img/330715_3_En_10_Figa_HTML.jpg

让我们在一个例子中尝试所有前面的步骤。按照每个步骤创建您的 zip 包:

  1. 创建一个可以通过命令提示符(如 c:\test)轻松访问的文件夹。

  2. 在该文件夹中,创建以下两个名为 setup.py 和 MyApp.py 的文件:

    #setup.py
    from distutils.core import setup
    setup(name='MyApp',
          version='0.1',
          author='Alchin and Browning',
          author_email='authors@propython.com',
          url='http://www.propython.com/',
    )
    # MyApp.py
    print("Hello Burton and Marty!")
    gone=input("Enter to close: ")
    
    
  3. Shell 退出到命令提示符下,进入测试目录,执行命令:

    python setup.py sdist  (Enter)
    
    
  4. 按回车键。(如果它没有启动 Python,您将需要检查您的搜索路径,并确保您的系统可以找到 Python。)

这将在测试文件夹中创建一个 dist 目录,其中包含您的包的 zip 文件。

当然,这只是一个非常简单的概述,但是您可以灵活地添加清单文件、更改压缩选项等等。

分配

一旦你有了这些文件,你将需要一种方法来把它们分发给公众。一种选择是简单地托管自己的网站,并从那里提供文件。这通常是向广大读者推销您的代码的最佳方式,因为您有机会以更易读的方式将文档放在网上,展示它的使用示例,提供已经在使用它的人的评价,以及您能想到的任何其他东西。

简单地自己托管它的唯一问题是,使用自动化工具很难找到它。许多软件包将依赖于其他应用的存在,因此能够从脚本内部直接安装它们通常是有用的,而不必导航到网站并找到正确的下载链接。理想情况下,他们能够将一个唯一的包名翻译成一种无需帮助就可以下载并安装该包的方式。

这就是 Python 包索引(PyPI) 6 发挥作用的地方。PyPI 的秘密代号是“奶酪店”,暗指约翰·克立斯试图从麦克·帕林经营的商店购买奶酪的蒙蒂蟒蛇奶酪店。。。没有可用的。

PyPI 是 Python 包的在线集合,它们都遵循标准化的结构,因此更容易被发现。每个包都有一个惟一的名称,可以用来定位它,索引跟踪哪个版本是最新的,并引用该包的 URL。你所需要做的就是把你的包添加到索引中,这样你的用户会更容易使用它。

第一次上传到 PyPI 需要在网站上注册。PyPI 帐户将允许您稍后管理您的应用详细信息,并上传新版本和更新。一旦你有了一个帐户,你就可以运行python setup.py register在 PyPI 上为你的应用建立一个页面。这是一个交互式脚本,将为您注册帐户提供三个选项:

  • 使用现有的 PyPI 帐户。如果您已经在 PyPI 网站上创建了一个帐户,您可以在这里指定您的用户名和密码。

  • 注册一个新的 PyPI 帐户。如果您想在命令行创建一个帐户,您可以在这里输入您的详细信息,并在注册时创建帐户。

  • 生成一个新的 PyPI 帐户。如果你想采用一种更简单的方法,这个选项将采用你已经在操作系统中使用的用户名,自动生成一个密码,并为该组合注册一个帐户。

一旦你选择了你的选项,注册脚本将会在本地保存你的账户信息,这样你就不用每次都经历那个步骤了。有了帐户后,脚本将使用setup.py中的信息向 PyPI 注册应用。特别是,namelong_description字段将组合成一个简单的网页,其他细节显示在一个列表中。

有了保存应用的页面,最后一步是使用upload命令上传代码本身。这必须作为发行版构建的一部分来完成,即使您之前已经构建了一个发行版。这样,您就可以准确地指定您想发送给 PyPI 的发行版的类型。例如,您可以在一个步骤中为 Windows 和非 Windows 用户上传包:

$ python setup.py sdist --format=zip,gztar upload

发行版文件是根据应用的名称和发行版创建时的版本号来命名的。PyPI 中的条目还包含对版本号的引用,因此您不能多次上传相同版本的相同发行版类型。如果你尝试,你会从setup.py得到一个错误,表明你需要创建一个新的版本号来上传一个改变的发行版。

令人兴奋的 Python 扩展:秘密模块

Secrets 模块为 Python 程序员提供了一些方便的随机数和密码生成工具。它的主要特点是随机数算法的加密特性。

Python 3.6 中引入的 secrets 模块有许多可用的函数。一个是随机数生成。虽然其他一些库已经介绍了这一点,但研究一下仍然很有意思。

您的计算机操作系统会考虑所生成的随机数的确切性质,但通常对于加密工作,这个随机库会比 Python 中的其他随机数生成器做得更好。这种加密用途包括:密码、认证和令牌。请继续阅读,看看这个模块有多方便。

随机数

有相当多的随机令牌和随机数生成选项。为了了解它们是如何工作的,考虑下一个例子将在 0 到 100 之间选择一个随机数。

img/330715_3_En_10_Figb_HTML.jpg

#Secrets example 1
from secrets import *
x=1
while (x <= 10):
    print(randbelow(100))
    x+=1

在前面的例子中,我们从 1 到 100 中选择了 10 个随机值。不令人兴奋,但随机值的更好的加密表示。接下来,我们将考虑随机密码生成。

密码生成

在下一个示例中,我们将使用字符串库和机密库来生成包含 ASCII 字母、数字、标点符号和大写字母的密码:

img/330715_3_En_10_Figc_HTML.jpg

#Generate six digit passwd with letters, digits, punct, and upper
import string
from secrets import *
chars = string.ascii_letters + string.digits + string.punctuation + string.ascii_uppercase
password = ".join(choice(chars) for i in range(6))
print (password)

如果你需要一个用于加密工作的令牌,有包括 urlsafe 在内的选项。考虑以下示例:

img/330715_3_En_10_Figd_HTML.jpg

#Generate a token value which is URL-safe
from secrets import *
value = token_urlsafe(10)
print('token is: ',value)

这里我们使用的是选择,但是使用这个库,您可以尝试以下操作:

img/330715_3_En_10_Fige_HTML.jpg

#Generate a secrets random choice
from secrets import *
value = choice(['one', 'two', 'three'])
print (value)

最后,如果您想输入值并从中选择一个随机集,请尝试以下方法:

img/330715_3_En_10_Figf_HTML.jpg

#Generate a random choice based on only certain values
from secrets import *
foo=input('Enter 10 random values to choose from:  ')
wow=“.join([choice(foo) for i in range(3)])
print('These are three exciting choices at random:>   ',wow)

这里没有什么可以从僵尸的启示中拯救世界,但是这些例子仍然是 Python secrets 模块非常有趣的用法。

带着它

如您所见,使用 PyPI 打包和分发 Python 应用的过程实际上相当简单。除了 PyPI,建立一个专门的项目网站通常是一个好主意,在那里你可以更好地推广和支持你的代码。永远记住,分销不是最后一步。你的用户在使用你的代码时会期望一定的支持和互动,并希望改进它,所以最好找到一种能为你和你的用户支持这些目标的媒介。

所有不同规模、受众和目标的应用都是公平的分配对象。无论您是在编写一个小工具来帮助自动化常见任务,还是编写一个完整的框架来为其他用户的代码提供一组功能,都没有关系。下一章将向你展示如何从头到尾建立这样一个框架,建立在贯穿本书的许多技术之上。

Footnotes 1

参见 GNU 操作系统,“GNU 通用公共许可证”, http://propython.com/gpl

  2

参见开源倡议,“按名称许可”, http://propython.com/osi-licenses

  3

参见 GNU 操作系统,“关于它们的各种许可和评论”, http://propython.com/fsf-licenses

  4

请参见分发 Python 模块”2。编写设置脚本, http://propython.com/distutils-setup

  5

见【Cygwin】http://propython.com/cygwin

  6

参见 Python 包索引(PyPl), http://propython.com/pypi

 

十一、工作表:CSV 框架

当然,编程最重要的是程序。如果工具、技术、哲学和建议从来没有被应用于解决现实世界的问题,那么它们根本没有什么价值。有时那个问题非常具体,但其他时候它仅仅是一个更普遍问题的具体例子。这些一般性问题通常是库和框架的主题,它们可以为更具体的应用提供基础。

这使得框架处于一个有趣的位置,因为它们更关注于服务开发者的需求,而不是普通用户。目标是提供一个基础和一套工具来帮助其他人开发更具体的应用。支持更广泛的用途需要比通常直接解决问题更先进的技术。

然而,为了对其他开发人员有用,理想的目标是提供一种翻译服务,以便框架使用的高级技术允许其他开发人员使用更简单的技术来执行那些更高级的任务。在这方面,框架设计非常类似于其他形式的设计,但不是主要集中在视觉用户界面上,而是集中在应用的编程接口,即 API 上。

像这样看待框架是很重要的,因为如果你正在写一个框架,你的读者正在寻找一个工具来节省他们的时间和精力,这样他们就可以专注于他们独特的需求。框架应该提供一组特性,鼓励与其他类型的应用集成,因此有必要考虑其他应用应该如何工作。

已经有无数使用框架的例子,服务于各种各样的需求。它们都解决一类通用的问题,比如用于 Web 开发的 Django 1 ,用于数据库交互的 SQLAlchemy 2 ,以及用于网络协议的 Twisted 3 。每一种都采用不同的方式向开发人员展示接口的风格和形式,突出了框架可以操作的各种方式。

本章将展示一个框架,它使用的声明性语法类似于 Django 和 Elixir 中使用的语法。这种方法的选择在很大程度上是基于风格的,即使有其他方法可以使用,详细研究其中一种方法将突出许多在编写框架时必须做出的决定。您将看到本书中展示的所有技术结合起来形成一个单一的、内聚的整体,公开一个提供许多有用特性的公共 API。

本章要解决的一个特殊问题是需要处理以逗号分隔值的行存储信息的文件,通常称为 CSV 文件。这些方法可用于分隔一行中的值、分隔行本身以及对每行中的单个值进行编码等任务,这就是为什么它会成为一个非常复杂的主题。

Python 已经提供了一个csv模块来帮助处理 CSV 文件。 4 与其试图复制它的功能,不如使用csv在幕后完成大部分繁重的工作。相反,我们要做的是在csv之上构建一个层,使它更容易与其他应用一起工作和集成。本质上,我们只是在现有 API 的基础上提供了一个新的 API,希望我们能让它更友好一些。

构建声明性框架

使用类似于 Django 或 Elixir 的声明性语法构建框架需要几个步骤,但是这个过程本身并不那么困难。然而,在此过程中做出决策是事情变得棘手的地方。在这一章中,我们将概述建立这样一个框架所需的各个步骤,以及你必须做出的许多决定的例子。然而,每一个都必须为你自己的项目特别制作。

但你不会孤军奋战。过程中的每一个决策点都会概述各种选择的利弊,这样你就可以自信地做出明智的选择。从一开始就做出正确的决策将有助于确保您的框架能够经受住未来的升级,以及那些不同意您观点的人的批评。只要确保你的决定背后有有效的、真实的推理,你就会没事。

本章不会只给你留下理论,而是会一步一步地创建一个框架,这个框架简单到足以介绍基本概念,而不必花太多时间在特定于其目的的事情上。它还需要是一个很好的例子,说明什么时候应该使用声明性框架,这首先需要我们理解我们真正在看什么。单词 step 对你来说是一个重要的术语,因为后面的例子会被添加进去,为什么它们是脚本。

介绍声明式编程

从本质上来说,声明性框架是一个助手,可以使声明性编程变得更容易——或者在某些情况下,成为可能。当然,如果没有定义是什么使它成为声明性的,那么这个定义是没有用的,但是谢天谢地,几乎不需要什么介绍。毕竟,您已经看到了声明式编程的实际应用,并且可能已经使用了很长一段时间,甚至可能没有意识到这一点。

声明式编程是告诉程序你想要什么(声明),而不是告诉它做什么(指示)。这种区别实际上更多的是关于程序员而不是程序,因为通常没有特殊的语法、解析或处理规则,也没有单一的方法来定义什么合格什么不合格。它通常被定义为命令式编程的对立面,在命令式编程中,程序员被期望概述计算机需要执行的每一个步骤。

考虑到这一点,很容易注意到更高级别的解释型语言,如 Python,比它们的低级同类,如 c,更适合于声明性编程。不必声明一个内存位置,指定它的类型,然后在内存中的那个位置存储一个值,只需分配一个变量,剩下的工作由 Python 完成。下面的代码生成一个名为 foo 的字符串变量,其中存储了“bar ”:

img/330715_3_En_11_Figa_HTML.jpg

>>> foo = 'bar'

这只是声明式编程的一种形式,使用一种语法。然而,当我们谈论 Python 中的声明性框架时,它通常指的是使用类声明来配置框架,而不是一组又长又复杂的配置指令。这是否是满足您需求的正确方法,需要对利弊进行更多的讨论。

建还是不建?

在过去的几年中,声明性框架在 Python 世界中已经成为一种上升趋势,但是理解它们并不总是解决给定问题的最佳方法是很重要的。像其他事情一样,决定是否使用声明式框架需要理解它到底是什么,它做什么,以及它对您的需求意味着什么。

声明式框架很好地将许多复杂的行为包装到一个简单的类声明中。这可以节省大量的时间,但是看起来也很像魔术,这是 Python 社区一直在与之斗争的东西。这是好是坏完全取决于您的 API 与用户对类声明的期望有多接近,以及您对那些期望可能失败的领域的文档记录有多好。

通过将类作为将您的意图传达给框架的主要方法,期望实例有意义是合理的。大多数情况下,实例指的是符合类声明定义的格式的一组特定数据。如果您的应用只处理一组定义明确的数据,那么拥有单独的实例就没什么用了。

声明性类旨在使用相同的框架创建许多不同的配置,每个配置都是为特定的数据配置而设计的。如果你只有一种数据格式可以使用——即使你有大量的数据——编写一个为可配置性而构建的框架是没有意义的。只需针对您的数据类型编写一个解决方案并使用它。

在其他情况下,您可能无法提前描述数据集的结构,而是必须根据所提供的数据来调整结构。在这些情况下,提供类声明没有什么价值,因为没有一个声明能够满足您正在处理的数据的需要。

对象的主要价值是通过实例方法对其内容执行操作的能力。因为一个声明性的框架会产生生成单个实例的定制类,所以这些实例应该能够执行有用的任务,如果没有框架的帮助,这些任务会变得更加困难。这不仅增加了它们的有用性,而且有助于确保生成的实例符合用户的期望。

回顾一下,声明性框架是一种很有价值的方法,如果您有:

  • 许多可能的配置

  • 每个配置都是预先知道的

  • 任何给定配置的许多实例

  • 可以在给定实例上执行的操作

本章描述的 CSV 框架需要处理大量可能的列和结构配置,每种类型都有许多示例文件。加载和保存数据等操作是常见的,而其他操作则是特定配置所特有的。

一旦完成,该框架将允许应用将 CSV 配置指定为如下类,并使用自动附加到该类的方法与它们进行交互。

为了确保你有合适的库,去pypi.python.org/pypi/Sheets/下载 Sheets ZIP 文件。将其解压缩,并将所有文件夹和文件放入 Python 3.x Lib 目录中(或者使用 pip 来安装):

img/330715_3_En_11_Figb_HTML.jpg

import sheets

class EmployeeSheet(sheets.Row):
    first_name = sheets.StringColumn()
    last_name = sheets.StringColumn()
    hire_date = sheets.DateColumn()
    salary = sheets.FloatColumn()

所以让我们开始吧。

构建框架

任何声明性框架都有三个主要组件,尽管其中一个可能有不同的形式,或者根本没有:

  • 一个 基类:因为声明式框架都是关于声明类的,拥有一个公共基类来继承给了框架一个地方来挂接和处理 Python 遇到的声明。附加到这个基类的元类提供了必要的机制来在运行时检查声明并进行适当的调整。基类还负责表示框架封装的任何结构的实例,通常有各种方法附加到简单的公共过程。

  • 各种字段类型:在类声明中有许多属性,通常称为字段。对于某些应用,更具体地称呼它们可能更有意义,但是对于本讨论,字段就足够了。这些字段用于管理由框架表示的结构中的单个数据属性,通常有不同的风格,每种风格都适合不同的一般数据类型,如字符串、数字和日期。字段的另一个重要方面是它们必须能够知道它们被实例化的顺序,因此声明中指定的顺序与后面使用的顺序相同。

  • 一个 选项容器:严格来说,这不是一个必要的组件,大多数框架都使用某种类型的类级选项,这不应该在每个单独的字段上指定,因为那样不会很枯燥。因为子类化除了基类的选择之外不提供任何选项,所以必须使用一些其他的结构来管理这些选项。这些选项的声明和处理方式在不同的框架之间会有很大的不同;没有任何句法或语义标准。为了方便起见,这个容器通常还管理附加到类的字段。

作为一种语法辅助,大多数声明性框架还确保所有这三个组件都可以从一个位置导入。这允许最终用户代码有一个更简单的导入块,同时在一个可识别的名称空间中包含所有必要的组件。这个名称空间的名称应该有意义,以便在最终用户代码中易于阅读。框架本身的名称通常是一个理想的选择,但是描述性很重要,所以在阅读时要确保它是有意义的。

尽管可以在这个过程的后期决定如何命名这个框架,但是在早期就想好一个名字是很有帮助的,哪怕只是命名包含下面几节中描述的模块的包。使用类似于csv的占位符目前可以很好地工作,但是因为 Python 有自己的csv模块——我们也将依赖于它——重用这个名称会导致很多问题。因为 CSV 文件通常用于在电子表格应用之间交换数据,我们将把我们的小框架称为sheets

看起来我们的旅程应该从基类开始,但是实际上这三个组件中的任何一个都可以是一个合理的起点。这往往取决于哪一块最需要思考,做的工作最多,或者首先需要测试。对于这个讨论,我们将从选项容器开始,因为它的创建不依赖于其他组件的实现细节。这避免了留下太多尚未描述的功能的模糊引用。

管理选项

选项组件的主要目的是存储和管理给定类声明的选项。这些选项不特定于任何一个字段,而是应用于整个类,或者用作单个字段可以选择性覆盖的默认值。现在,我们将把如何声明这些选项的问题放在一边,只关注容器本身及其相关需求。

从表面上看,选项只是名称到值的映射,所以我们可以使用一个简单的字典。毕竟,Python 有一个很棒的字典实现,简单肯定比复杂好。然而,编写我们自己的类为我们提供了一些非常方便的额外特性。

首先,我们可以验证为给定类定义的选项。可以根据它们的单个值、它们与其他选项的组合、它们对于给定执行环境的适用性以及它们是否是已知选项来验证它们。使用字典,我们只能简单地允许任何选项有任何类型的值,即使这毫无意义。

选项中的错误只有在依赖它们的代码因为它们不正确或丢失而阻塞时才会被发现,而这种类型的错误通常不是非常具有描述性的。对自定义对象进行验证意味着我们可以向试图使用不正确或无效选项的用户提供更有用的消息。

使用自定义类还意味着我们添加自己的自定义方法来执行任务,这些任务虽然有用,但要么是重复的,要么不属于其他任何地方。验证方法可以验证所有包含的选项是否合适,如果不合适,则显示有用的消息。还要记住,选项容器经常管理字段,因此可以为此添加一些方法;这些将在本节稍后介绍。

事实上,通过结合这两个特性,options 类甚至可以在所提供选项的上下文中验证字段声明。试着用一本普通的字典做那件事。

因为它可能最终封装了相当多的功能,我们将为选项容器设置一个新的模块,明确地命名为options.py。像大多数类一样,大部分工作将在__init__()方法中完成。出于我们的目的,这将接受所有已知的选项,将它们作为属性存储起来,并设置一些其他的属性,这些属性将在以后被其他方法使用。验证通常只在主动定义选项时有用,所以它属于自己的方法,以免陷入这个方法。

因此,我们来到你的框架中的下一个决定:你应该接受什么选择?不同的框架显然会有不同的需求,在一开始就尽可能完整地将它们布局出来是很重要的。别急,随时可以补充更多;最好是让他们早一点到位,而不是晚一点。

一个有用的经验法则是,选项应该总是有默认值。要求你的用户不仅编写一个类和提供字段,而且每次都提供选项会令人沮丧,特别是如果必需的选项经常具有相同的值。一般来说,如果某些东西确实是必需的,并且没有合理的默认值,那么它应该作为一个参数提供给需要它的方法,而不是定义为类的一个选项。

我们正在构建一个与 CSV 文件接口的框架,因此有许多选项可用。也许最明显的是文件的字符编码,但是当文件以文本模式打开时,Python 已经将文件内容转换为 Unicode。open()函数接受一个encoding参数,该参数允许字符串的encode()方法使用所有相同的编码。它默认为 UTF-8,这应该足以满足大多数常见的需求。

注意

读取文件时使用的编码似乎是一个完美的选择,因此您可以覆盖默认的 UTF-8 行为。不幸的是,标准的 CSV 接口要求文件在传入时已经打开,所以如果我们的框架遵循相同的接口,我们无法控制编码。控制它的唯一方法是改变接口来接受一个文件名而不是一个打开的文件对象。

CSV 文件中的一个常见变化是它们是否包含标题行,包含各列的标题。因为我们稍后将在框架中将列定义为字段,所以我们并不真正需要那个标题行,所以我们可以跳过它。但前提是我们知道它在那里。一个简单的布尔值(在更常见的情况下默认为False)就能很好地完成这个任务:

img/330715_3_En_11_Figc_HTML.jpg

class Options:
    """
    A container for options that control how a CSV file should be handled when
    converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.
    """

    def __init__(self, has_header_row=False):
        self.has_header_row = has_header_row

这里我们有一个简单但有用的选项容器。在这一点上,它相对于字典的唯一好处是,除了我们指定的选项之外,它会自动禁止任何其他选项。稍后我们会回来添加一个更严格的验证方法。

如果你熟悉 Python 的csv模块,你可能已经知道它包含了多种选项,作为对不同方言支持的一部分。因为sheets实际上将遵从该模块的大部分功能,所以除了我们自己的选项之外,支持所有相同的选项是有意义的。事实上,为了更好地反映已经在使用的词汇,重命名我们的OptionsDialect是有意义的。

然而,让我们采取一种更具前瞻性的方法,而不是单独列出所有受csv支持的选项。我们依赖于我们控制之外的代码,试图跟上代码在未来可能引入的任何变化有点麻烦。特别是,我们可以支持任何现有的选项以及任何未来的选项,只需将任何额外的选项直接传递给csv本身。

为了在不命名选项的情况下接受它们,我们转向 Python 对使用双星号语法的额外关键字参数的支持。这些额外的选项可以作为字典存储起来,稍后将被传递给csv函数。接受它们作为一组关键字参数而不是一个字典有助于统一所有的选项,这在我们实际解析类声明之外的选项时非常重要:

img/330715_3_En_11_Figd_HTML.jpg

class Dialect:
    """
    A container for dialect options that control how a CSV file should be
    handled when converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.

    For a list of additional options that can be passed in, see documentation
    for the dialects and formatting parameters of Python's csv module at
    http://docs.python.org/library/csv.html#dialects-and-formatting-parameters
    """

    def __init__(self, has_header_row=False, **kwargs):
        self.has_header_row = has_header_row
        self.csv_dialect = kwargs

这个类以后会增加更多的特性,但这已经足够了。在我们结束之前,我们还会再讨论几次,但是现在,让我们继续讨论这个小框架中最重要的部分:字段。

定义字段

字段通常只是特定数据的容器。因为它是一个通用术语,不同的学科可能会用更具体的东西来指代同一个概念。在数据库中,它们被称为列。在表单中,它们通常被称为输入。当执行一个函数或程序时,它们被称为参数。为了保持这个框架之外的一些观点,本章将把所有这样的数据容器称为字段,尽管对于sheets本身,术语“列”在命名单个类时更有意义。

首先要定义的是一个基本字段类,它将描述字段的含义。没有任何特定数据类型的任何细节,这个基类管理字段如何与系统的其余部分相适应,它们将有什么 API,以及子类被期望如何行为。因为我们的框架称它们为列,我们将启动一个名为columns.py的新模块并开始工作。

字段是 Python 对象,作为类声明的一部分进行实例化,并作为类的属性进行赋值。因此,__init__()方法是进入字段功能的第一个入口点,也是唯一可以将字段配置为声明的一部分的地方。__init__()的参数可能因字段类型而异,但通常至少有几个参数适用于所有字段,因此可以由基类处理。

首先,每个字段可以有一个标题。这使得代码更具可读性和可理解性,同时也为其他工具自动记录字段提供了一种方法,这些工具不仅记录了字段的属性名称,还记录了更有用的信息。计划验证不会有什么坏处,所以我们还将添加一种方法来指示字段是否是必需的:

img/330715_3_En_11_Fige_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

注意标题是可选的。如果没有提供标题,可以从字段被分配到的属性名称中收集一个简单的标题。不幸的是,这个领域还不知道这个名称是什么,所以我们将不得不在以后回来使用这个功能。我们还假设大多数字段都是必需的,所以这是默认设置,可以基于每个字段被覆盖。

小费

必填字段可能看起来对 CSV 框架没有太大价值,因为数据来自文件而不是直接来自用户,但它们可能很有用。对于像sheets这样的东西,它最终可以验证传入的文件或者将要保存到传出文件的数据。对于任何框架来说,在一开始就包含这个特性,以支持以后可以添加的特性,通常是一个好的特性。

对于框架的字段,您可能已经有了其他的想法。如果是这样,现在可以按照相同的基本模式随意添加它们。不过,不要担心一开始就计划好一切;以后还有很多机会添加更多。下一步是将字段正确地连接到它们相关的类。

将字段附加到类

我们需要设置钩子来从字段被分配到的类中获取额外的数据,包括字段的名称。这个新的attach_to_class()方法——顾名思义——负责将字段附加到它被分配到的类。即使 Python 自动地将属性添加到它们被赋值的类中,这种赋值并没有向属性传递任何东西,所以我们必须在元类中这样做。

首先,我们需要决定属性需要知道哪些关于它是如何被赋值的信息。在前一节准备了标题之后,很明显属性需要知道在分配时它被赋予了什么名称。通过在代码中直接获得该名称,我们可以避免将名称作为属性实例化的参数单独写出的麻烦。

框架的长期灵活性还将依赖于为属性提供尽可能多的信息,以便它们可以通过自省它们所附加的类来轻松提供高级功能。不幸的是,名字本身并没有说明属性现在所在的类,所以我们也必须在元类中提供这个属性。

最后,之前定义的选项,比如encoding,会对属性的行为产生一些影响。与其期望属性必须根据传入的类来检索这些选项,不如简单地接受这些选项作为另一个参数。这留给我们一个看起来像这样的attach_to_class():

img/330715_3_En_11_Figf_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, options):
        self.cls = cls
        self.name = name
        self.options = options

仅这一点就允许属性对象的其他方法访问大量的信息,比如类名、在它上面声明的其他属性和方法、它是在哪个模块中定义的等等。然而,我们需要对这些信息执行的第一个任务要稍微平凡一些,因为我们仍然需要处理标题。如果在创建属性时没有指定标题,此方法可以使用名称来定义一个标题:

img/330715_3_En_11_Figg_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, options):
        self.cls = cls
        self.name = name
        self.options = options
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')

这种添加接受带下划线的属性名,并使用多个单词将其转换为标题。我们可以强加其他约定,但是这足够简单,在大多数情况下是准确的,并且符合常见的命名约定。这种简单的方法将涵盖大多数用例,并且不难理解或维护。

正如评论所指出的,这个新特性的if测试违背了标准习惯用法,它明确地检查了None,而不是简单地让一个未指定的标题评估为False。在这里以“正确”的方式做事将会消除将空字符串指定为标题的能力,这可以明确地表示没有标题是必要的。

检查None允许空字符串仍然保留该字符串作为标题,而不是用属性名替换它。空标题有用的一个例子是作为一种方式来指示该列不需要在文件数据的显示中呈现。这也是一个很好的例子,说明注释对于理解一段代码的意图是至关重要的。

小费

尽管这个attach_to_class()方法不使用所提供的选项,但是将它包含在协议中通常是一个好主意。下一节将展示选项将作为类的一个属性可用,但是将它作为自己的参数传递会更清楚一些。如果您的框架需要将这些类级别的选项应用到单个字段,那么接受它作为参数比从类中提取它更容易。

添加元类

有了attach_to_class()方法,我们现在必须进入等式的另一边。毕竟attach_to_class()只能接收信息;元类负责提供这些信息。直到现在,我们甚至还没有开始研究这个框架的元类,所以我们需要从基础开始。

通过子类化type,所有的元类开始都是一样的。在这种情况下,我们还将添加一个__init__()方法,因为我们所需要的就是在 Python 完成它们之后处理类定义的内容。首先,元类需要识别类中定义的任何选项,并创建一个新的Dialect对象来保存它们。有几种方法可以解决这个问题。

最明显的选择是简单地将选项定义为类级别的属性。这将使以后定义单独的类变得容易,但是它会带来一些可能不太明显的问题。首先,它会搞乱主类名称空间。如果您试图创建一个类来处理包含编码文档信息的 CSV 文件,那么您可能有一个名为encoding的列。因为我们也有一个名为encoding的类选项,所以我们必须给我们的列起一个别的名字,以避免其中一个覆盖另一个并导致问题。

更实际的情况是,如果选项包含在它们自己的名称空间中,就更容易挑选出来。通过能够容易地识别哪些属性是选项,我们可以将它们作为参数传递给Dialect,并且立即知道是否有任何属性丢失或者是否指定了无效的名称。所以现在的任务是确定如何为选项提供新的名称空间,同时仍然将它们声明为主类的一部分。

最简单的解决方案是使用内部类。除了其他属性和方法,我们可以添加一个名为Dialect的新类来包含各种选项赋值。这样,我们可以让 Python 为我们创建和管理额外的名称空间,这样我们所要做的就是在属性列表中查找名称Dialect并将其提取出来。

小费

尽管内部的Dialect类与其他属性和方法一起驻留在主名称空间中,但是冲突的可能性要小得多,因为它只有一个名称,而不是几个。此外,我们使用以大写字母开头的名称,这对于属性和方法名称是不鼓励的,因此冲突的可能性更小。因为 Python 名称是区分大小写的,所以您可以自由地在类上定义一个名为dialect(注意小“d”)的属性,而不用担心会碰到这个Dialect类。

为了提取这个新的Dialect类,我们将转向这个框架中元类的第一个实现。因为这将有助于形成未来继承的基类,我们将把代码放入一个新的模块中,命名为base.py:

img/330715_3_En_11_Figh_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs['Dialect'].__dict__.items()
            items = dict((k, v) for (k, v) in items if not k.startswith('__'))
        else:
            # No dialect options were explicitly defined
            items = {}
        dialect = options.Dialect(**items)

既然选项已经从类定义中提取出来,并且已经填充了一个Dialect对象,我们将需要对这个新对象做一些事情。我们从上一节中对attach_to_class()的定义中知道,它被传递给每个已定义的字段属性的方法,但是还有什么呢?

本着为以后保留尽可能多的信息的精神,我们将把它分配给类本身。但是因为大写的名字不如属性名好用,所以最好改名为更合适的名字。因为它还形成了框架内部工作的私有接口,所以我们可以在新名称前加上下划线,以进一步防止任何意外的名称冲突:

img/330715_3_En_11_Figi_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

这个简单的更改将它从原来的类名称空间中移除,并以新名称_dialect插入。这两个名称都避免了与公共属性名称的冲突,但是这一更改使它使用了一个更标准的私有属性名称。以前,它使用标准样式命名一个类,因为这是它的定义方式。

至此,我们终于有了继续处理字段属性的所有内容。第一个任务是在类定义中找到它们,并对找到的任何一个调用attach_to_class()。这可以通过一个简单的属性循环轻松实现:

img/330715_3_En_11_Figj_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

这个简单的元类包含一个循环,该循环只检查每个属性,看它是否有一个attach_to_class()方法。如果是,则调用该方法,传入类对象和属性的名称。这样,所有的列都可以在过程的早期获得它们需要的信息。

鸭子打字

这个元类使用 hasattr()来检查 attach_to_class()方法的存在,而不是简单地检查属性是否是 Column 的实例。Column 的所有实例都应该有必要的方法,但是通过使用 hasattr(),我们可以为任何类型的对象打开它。您可以将 attach_to_class()添加到其他类型的属性、描述符甚至方法中,从而快速方便地访问更高级的功能。元类只检查它到底需要什么,其余的留给灵活性,这是 duck 类型化的主要好处。这个名字来自于众所周知的鸭子测试的应用概念,该测试声明“如果它像鸭子一样摇摇摆摆,像鸭子一样嘎嘎叫,那么它一定是一只鸭子,”以确定是否应该使用一个对象。

现在,填写剩下的base.py所需要的就是包含一个真正的基类,单个 CSV 定义可以继承这个基类。因为每个子类都是电子表格中的一行,所以我们可以将基类命名为Row来表示它的用途。目前它需要做的就是将RowMeta作为它的元类,它将自动获得必要的行为:

img/330715_3_En_11_Figk_HTML.jpg

#in base.py
class Row(metaclass=RowMeta):
    pass

将它整合在一起

从技术上讲,现在所有的部分都已经就绪,至少可以演示一个工作系统的基础,但是仍然有一个重要的部分需要处理。目前我们有三个不同的模块,每个模块都有一些需要在公共 API 中公开的部分。理想情况下,所有重要的部分都应该可以从一个中心导入获得,而不是三个,甚至更多。

如果您还没有创建模块,那么在与前面提到的其他脚本相同的目录中创建一个__init__.py模块。该文件可以是空的,并且仍然能够单独导入所有的包,但是只需一点努力,就可以更好地利用它。因为这是在简单地直接导入包名时导入的文件,所以我们可以使用它作为触发器,从所有其他文件中提取有用的部分:

img/330715_3_En_11_Figl_HTML.jpg

打开__init__.py,把这段代码放进去:

from sheets.base import *
from sheets.options import *

from sheets.columns import *

注意

通常,使用星号来导入所有内容是一个坏主意,因为它会使识别什么来自哪里变得更加困难。因为这个模块只是导入代码,并不做任何事情,所以这个问题并不存在。只要包是自己导入的,比如import sheets,就不会有对象来自哪里的困惑。因为我们不需要提到任何对象的名字,这也适用于我们可能添加到那些模块中的任何东西。

现在我们有足够的工作部件来表明框架可以工作,至少在非常基础的水平上。如果我们从框架代码本身向上创建一个example.py目录,那么sheetsPYTHONPATH上,我们现在可以创建一个类,它做一些非常简单的工作来显示它开始组合在一起:

img/330715_3_En_11_Figm_HTML.jpg

import sheets

class Example(sheets.Row):
    title = sheets.Column()
    description = sheets.Column()

if __name__ == '__main__':
    print(Example._dialect)
    print(Example.title)

然而,到目前为止,这实际上只是允许我们命名列。为了将它们与 CSV 文件中的数据对齐,我们需要知道字段在类中定义的顺序。

排序字段

目前,这些字段都可以作为类本身的属性使用。这允许您获得关于单个字段的一些信息,但前提是您知道字段的名称。如果没有名字,你就必须检查这个类的所有属性,并检查哪些属性是Column或者它的子类的实例。然而,即使您这样做了,您仍然不知道它们被定义的顺序,所以不可能用 CSV 文件中的数据来排列它们。

为了解决这两个问题,我们需要建立一个列列表,其中每个列都可以按照定义的顺序存储。但是首先,我们需要能够在运行时识别订单,而不需要询问开发人员。至少有三种不同的方法可以做到这一点,每种方法都有自己的好处。

宣布目标。__ _ _ _ 准备 _ _()

第四章展示了当 Python 处理组成类定义的代码块时,元类可以控制类名称空间的行为。通过在声明性元类上包含一个__prepare__()方法——在本例中是RowMeta——我们可以提供一个有序字典,然后它可以保持属性赋值本身的顺序。这就像导入一个有序字典实现并从一个定制的__prepare__()方法返回它一样简单:

img/330715_3_En_11_Fign_HTML.jpg

from collections import OrderedDict

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)

        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

    @classmethod
    def __prepare__(self, name, bases):
        return OrderedDict()

然而,这只是我们前进的一部分。现在名称空间字典包含了所有的类属性,并且知道它们的定义顺序,但是它没有解决只有 CSV 列的简单列表的问题。名称空间字典还将保存所有已定义的方法和其他各种属性,所以我们仍然需要从其中取出列,并将它们放入另一个列表中。

一个显而易见的方法是查看字典中的每个属性,检查它是否是列。这与本节前面提到的过程相同,但是现在的不同之处在于,您可以将复杂性隐藏在元类内部。

因为__init__()在整个主体被处理后运行,所以它的attrs参数将是一个包含所有属性的有序字典。剩下的工作就是循环遍历它们,找出找到的所有列。同样,本着鸭子打字的精神,我们将使用attach_to_class()来确定哪些属性是列。事实上,我们可以使用现有的循环,只需将新代码注入到内部的if块中。

为了在现实世界中使用它,需要将它放在更有用的地方,比如存储在类的_dialect属性中的Dialect对象。与其简单地从外部分配一个列表,不如让Dialect自己管理它,给它一个add_column()方法,我们可以从元类调用它:

img/330715_3_En_11_Figo_HTML.jpg

class Dialect:
    """
    A container for dialect options that control how a CSV file should be
    handled when converting it to a set of objects.

    has_header_row
        A Boolean indicating whether the file has a row containing header
        values. If True, that row will be skipped when looking for data.
        Defaults to False.

    For a list of additional options that can be passed in, see documentation
    for the dialects and formatting parameters of Python's csv module at
    http://docs.python.org/library/csv.html#dialects-and-formatting-parameters
    """

    def __init__(self, has_header_row=False, **kwargs):
        self.has_header_row = has_header_row
        self.csv_dialect = kwargs
        self.columns = []

    def add_column(self, column):
        self.columns.append(column)

既然Dialect知道如何保存字段记录,那么只需修改RowMeta就可以在发现列时将其添加到方言中。因为名称空间已经根据属性分配的时间进行了排序,所以我们可以确保它们以正确的顺序附加到类上。因此,我们可以简单地在列的attach_to_class()方法中添加对方言的add_column()的快速调用:

img/330715_3_En_11_Figp_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

注意

这个例子还将属性options的名称改为dialect,以与框架的其余部分保持一致。

现在,我们的代码有了一种简单的方法,可以按照原始顺序获取提供给类的列。然而,它有一个相当大的缺陷:__prepare__()技术只在 Python 版中可用。因为在此之前没有等效的功能,任何旧版本都需要使用完全不同的方法来解决这个问题。

我们可以利用 Python 的类处理的基本原则:类的主体作为代码块执行。这意味着每个列属性都是按照它们在类定义中的写入顺序进行实例化的。Column类已经有了一个在属性被实例化时运行的代码块,它可以被扩展一点以跟踪每个实例化。

专栏。init()

最明显的选择是我们已经有代码的地方:__init__()方法。当每个Column对象被实例化时,它被调用,因此它提供了一个方便的地方来跟踪这些对象被遇到的顺序。实际过程相当简单。它所需要的只是一个无论处理哪一列都可以在一个地方维护的计数器,以及每当发现一个新列就递增该计数器的一小段代码:

img/330715_3_En_11_Figq_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    # This will be updated for each column that's instantiated.
    counter = 0

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required
        self.counter = Column.counter
        Column.counter += 1

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

这段代码处理了部分问题。现在,每一列都有一个counter属性,表明它在其余列中的位置。

简单比复杂好

实际上,该计数器将跨所有的列维护,而不管它们被分配到哪个类。尽管这在技术上有点过分,但实际上不会伤害任何东西。每组列仍将在其对等列之间适当排序,因此它们可以正确排序而不会出现问题。更重要的是,重置每个类的计数器会使代码变得非常复杂。

首先,我们需要为每个可以附加列的类创建一个单独的计数器。在调用attach_to_class()之前,列不知道它们被分配到哪个类,所以我们必须在其中放一些代码来确定新类何时被处理。但是因为这是在计数器已经在__init__()中递增之后发生的,所以在将计数器分配给新类的新位置时,需要重置计数器。

为每个单独的类保留一个单独的计数器是完全可能的,但是这样做并不会给这个过程增加任何东西。因为更简单的形式在大多数情况下都是有效的,所以增加复杂性是不值得的。如果您有一个长期运行的进程,它定期动态地创建Row子类,那么计数器可能会溢出并导致问题。在这种情况下,您需要采取这些额外的步骤来确保一切继续正常工作。

下一步是使用该计数器强制对存储在Dialect对象上的列进行排序。在__prepare__()方法中,名称空间自己处理排序,所以没有其他事情要做。这里我们需要对字段列表进行显式排序,使用counter属性来确定顺序。

我们不能马上在__init__()中这样做,因为那样会得到所有属性的字典,而不仅仅是列。在使用它们的attach_to_class()方法进行处理之前,它不知道哪些属性是列。在用attach_to_class()处理完所有的列之后,对列表进行排序将会提供一个完整的列表,其中只有正确顺序的列。以下是您需要添加到RowMeta类的内容:

img/330715_3_En_11_Figr_HTML.jpg

from sheets import options

class RowMeta(type):
    def __init__(cls, name, bases, attrs):
        if 'Dialect' in attrs:
            # Filter out Python's own additions to the namespace
            items = attrs.pop('Dialect').__dict__.items()
            items = {k: v for k, v in items if not k.startswith('__')}
        else:
            # No dialect options were explicitly defined
            items = {}
        cls._dialect = options.Dialect(**items)
        for key, attr in attrs.items():
            if hasattr(attr, 'attach_to_class'):
                attr.attach_to_class(cls, key, cls._dialect)

        # Sort the columns according to their order of instantiation
        cls._dialect.columns.sort(key=lambda column: column.counter)

这个函数调用可能看起来比实际复杂一点。它只是调用一个标准的sort()操作,但是有一个函数将被调用来确定在排序项目时使用什么值。我们可以给Column添加一个方法,只返回计数器并使用它,但是因为它只在这里使用,一个lambda函数将内联做同样的工作。

简单比复杂好

另一种选择是在处理attach_to_class()的同时对列表进行排序。前面显示的默认attach_to_class()实现已经在提供的Dialect对象上调用了add_column(),所以这是一个很好的地方来完成这项工作。不幸的是,这样做需要一些额外的步骤。每次添加新列时尝试对整个列表进行排序是没有意义的,但是我们可以使用标准库中的bisect模块来更高效地保持顺序。

二等分模块提供了一个insort()方法,该方法将新项目插入到现有序列中,同时保留这些项目的有用顺序。然而,与标准的sort()不同,这个函数不接受关键参数,而是依赖于使用<操作符来比较两个项目。如果一个项目比另一个项目小,它在序列中会被放在更靠前的位置。这很有意义,但是如果不使用显式的key,我们需要在Column类上实现一个__lt__()方法来支持insort()

事后排序只需要一行额外的代码,而尝试从头到尾排序会引入另一个导入和另一个对Column类的方法。通过这种方式,我们得到的唯一好处是能够看到到目前为止已经处理过的所有列的顺序,但是因为新列可能被放置在该顺序内的任何位置,所以在所有列都被处理完之前,它实际上没有多大用处。因此,最好保持事情简单,然后只对列表进行一次排序。

这种方法中添加的大部分代码在__prepare__()不可用时都是必需的,不管其他偏好如何。我们真正有空间使用不同方法的唯一领域是更新计数器值的地方。有几种不同的方法来管理这个价值。

到目前为止,我们已经使用了Column类的__init__()方法,因为它总是在实例化期间被调用,而且它已经有了一个基本的实现。麻烦在于,许多__init__()方法仅用于将参数值作为属性保存在对象上,因此程序员已经开始期待类似的行为。除了管理计数器,我们自己的__init__()方法完全符合这一期望。

因此,如果程序员想编写一个新的列,它不使用任何与基类Column相同的参数,那么很容易编写一个不调用super()__init__()方法。如果不使用super()来触发最初的__init__()方法,新列将不会被正确排序。它的counter属性将始终与它之前处理的内容相同,因此sort()将无法可靠地确定它属于哪里。

你可能会说这里的问题在于程序员认为__init__()不做任何有价值的事情,但这不是解决问题的有效方法。如果有人忘记使用super(),我们仍然有一些方法可以让框架的用户变得更容易,有助于避免问题。

专栏。__ 新 _ _()

想想没有__init__()的实例化,下一个明确的选择是__new__(),它在流程的前面被调用。使用__new__()提供了一个不与__init__()竞争而做相同工作的机会,所以它们可以相互独立。对象的初始化仍然可以在__init__()中进行,让__new__()来管理计数器值:

img/330715_3_En_11_Figs_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    # This will be updated for each column that's instantiated.
    counter = 0

    def __new__(cls, *args, **kwargs):
        # Keep track of the order each column is instantiated
        obj = super(Column, cls).__new__(cls, *args, **kwargs)
        obj.counter = Column.counter
        Column.counter += 1
        return obj

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

因为__new__()负责创建和返回新对象,所以__new__()中的代码比之前在__init__()中使用的有所增加。因此,我们需要在给对象分配计数器之前显式地创建对象。然后,该方法需要显式返回新对象,以便其他任何对象都可以访问它。

使用__new__()而不是__init__()仅仅是一种减少与定制实现冲突的方法。这可能不太可能,但是子类自己提供__new__()仍然是可能的,不使用super()这样做仍然会导致问题。还有一个选项可以进一步区分计数行为。

反击。call()

重要的是要明白,在实例化一个类时,还会调用另一个方法。从技术上讲,类对象本身是作为一个函数被调用的,这意味着在某个地方有一个__call__()方法会被调用。因为__call__()只作为一个实例方法执行,但是实例化发生在调用一个类的时候,我们需要把这个类看作其他东西的实例:一个元类

这意味着我们可以创建一个元类来完全在Column类之外支持计数器功能。一个简单的带有__call__()方法的CounterMeta类可以自己跟踪计数器,然后Column可以使用它作为它的元类。这个方法的主体看起来本质上就像__new__(),因为它被称为过程中几乎相同的部分。需要使用super()创建对象并显式返回:

img/330715_3_En_11_Figt_HTML.jpg

class CounterMeta(type):
    """
    A simple metaclass that keeps track of the order that each instance
    of a given class was instantiated.
    """

    counter = 0

    def __call__(cls, *args, **kwargs):
        obj = super(CounterMeta, cls).__call__(*args, **kwargs)
        obj.counter = CounterMeta.counter
        CounterMeta.counter += 1
        return obj

现在,所有这些功能都被隔离到一个元类中,Column类变得简单了一些。它可以去掉所有的计数器处理代码,包括整个__new__()方法。现在维护计数行为所需要的就是使用CounterMeta作为它的元类:

img/330715_3_En_11_Figu_HTML.jpg

class Column(metaclass=CounterMeta):
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

事实上,这个CounterMeta现在能够为任何需要它的类提供这种计数行为。通过简单地应用元类,给定类的每个实例都有一个附加的counter属性。然后,您可以使用该计数器根据实例化的时间对实例进行排序,就像sheets框架中的列一样。

选择一个选项

在这里提供的选项中,决定选择哪一个并不总是容易的。随着每一层灵活性的增加,复杂性也随之增加,最好是尽可能保持简单。当在 Python 3.x 环境中工作时,__prepare__()无疑是最好的选择。它不需要任何额外的类来支持它;它不需要在事实之后对列列表进行排序;而且它根本不需要接触Column类就可以工作。

Python 2 . x 早期版本的选项更加主观。你选择哪一个很大程度上取决于你对你的目标读者的期望,以及你允许你的代码有多复杂。更简单的解决方案需要用户更加警惕,所以你需要决定什么是最重要的。

因为这本书是为 Python 3.x 设计的,所以剩下的代码示例将使用__prepare__()。当然,对一组字段进行排序的能力只有在您有一组要处理的字段时才有用。

建立野外图书馆

在大多数声明性框架中,字段的主要功能是在本地 Python 对象和一些其他数据格式之间转换数据。在我们的例子中,另一种格式是包含在 CSV 文件中的字符串,因此我们需要一种方法在这些字符串和字段表示的对象之间进行转换。在我们进入具体字段类型的细节之前,我们需要设置一些管理数据转换的方法。

第一个方法to_python(),从文件中获取一个字符串,并将该字符串转换成一个本地 Python 值。每次从文件中读入一行时,都会对每一列执行该步骤,以确保您可以在 Python 中使用正确类型的值。因为不同类型的行为会有所不同,委托给像to_python()这样的方法允许您在单个类上改变这种特定的行为,而不必在一个Column类上这样做。

第二个方法是to_string(),它是to_python()的逆方法,在保存带有 Python 中赋值的 CSV 文件时会被调用。因为默认情况下csv模块处理字符串,所以该方法用于提供特定 CSV 格式所需的任何特殊格式。委托给该方法意味着每一列都可以有自己的选项来适应属于该字段的数据。

尽管每种类型的数据行为不同,但默认情况下,基类Column可以支持一个简单的用例。csv模块只处理以文本模式打开的文件,所以 Python 自己的文件访问在读取数据时管理到 Unicode 的转换。这意味着来自csv的值已经是一个字符串,可以很容易地使用:

img/330715_3_En_11_Figv_HTML.jpg

class Column:
    """
    An individual column within a CSV file. This serves as a base for attributes
    and methods that are common to all types of columns. Subclasses of Column
    will define behavior for more specific data types.
    """

    def __init__(self, title=None, required=True):
        self.title = title
        self.required = required

    def attach_to_class(self, cls, name, dialect):
        self.cls = cls
        self.name = name
        self.dialect = dialect
        if self.title is None:
            # Check for None so that an empty string will skip this behavior
            self.title = name.replace('_', ' ')
        dialect.add_column(self)

    def to_python(self, value):
        """
        Convert the given string to a native Python object.
        """
        return value

    def to_string(self, value):
        """
        Convert the given Python object to a string.
        """
        return value

现在我们可以开始为单个数据类型实现它们了。

斯普林菲尔德

最明显的开始字段是字符串,因为它可以包含任意数量的更具体形式的数据。标题、姓名、地点、描述和评论只是这些字段中更具体的值的一些例子,但是从技术角度来看,它们都以相同的方式工作。sheets 框架不需要关心你将要处理什么形式的字符串,只需要知道它们实际上都是字符串。

csv模块自己提供了字符串,所以这个类实际上不需要做太多事情。事实上,to_python()to_string()根本不需要任何定制的实现,因为它们只需要返回给它们的东西。StringColumn提供的最重要的东西实际上是名称本身。

通过拥有根据与之交互的数据类型命名的属性,属性在某种程度上变得不言自明。不要仅仅使用一个通用的Column来描述字符串是如何来回传递的,你可以使用一个StringColumn来明确它是如何工作的:

img/330715_3_En_11_Figw_HTML.jpg

class StringColumn(Column):
    """
    A column that contains data formatted as generic strings.
    """
    pass

事实上,您甚至可以调用基类StringColumn而不仅仅是Column,因为它自己完成这项工作。不幸的是,这在子类化它的时候会引起混乱,因为需要像IntegerColumn这样的东西来子类化StringColumn。为了让事情更清楚,基类将保持Column,每个子类将只在它上面添加必要的特性,即使除了名字之外没有任何有用的东西可以添加。

整数列

下一个要添加的字段类型管理整数。数字在电子表格中使用得相当多,存储从年龄到销售数字到库存计数的一切。大多数情况下,这些数字是普通整数,可以使用内置的int()函数轻松转换:

img/330715_3_En_11_Figx_HTML.jpg

class IntegerColumn(Column):
    """
    A column that contains data in the form of numeric integers.
    """
    def to_python(self, value):
        return int(value)

IntegerColumn实际上并不需要实现一个to_string()方法,因为csv模块会自动调用str(),无论它被赋予什么值。因为这就是我们在to_string()方法中所做的一切,我们可以忽略它,让框架来处理这个任务。正如您将在其他列中看到的,当列可以指定更明确的格式时,to_string()最有用。简单地写出一个数字并不需要太多的灵活性。

浮动柱

电子表格中的许多数字比整数粒度更细,需要额外的信息来传递小数点后的值。浮点数是处理这些值的一种很好的方式,将它们作为一列来支持就像使用IntegerColumn一样简单。我们可以简单地用float替换int的所有实例,这样就完成了:

img/330715_3_En_11_Figy_HTML.jpg

class FloatColumn(Column):
    """
    A column that contains data in the form of floating point numbers.
    """
    def to_python(self, value):
        return float(value)

当然,在许多情况下,当查看浮点数或者将它们加在一起时,浮点数也有问题。这是由于小数点缺少定义的精度造成的:它根据给定值在代码中表示的好坏而浮动。为了更加明确和避免舍入误差之类的事情,我们求助于DecimalColumn

十进制柱

FloatColumn一样,它可以处理整数以外的数字。然而,DecimalColumn将依赖 Python 提供的decimal模块的功能,而不是使用浮点数。小数值尽可能多地保留原始数字的细节,这有助于防止舍入误差。这使得小数更适合用于货币电子表格。

在 Python 中,小数是使用decimal模块提供的,该模块提供了一个Decimal类来管理单个数字。因此,DecimalColumn需要将数字从 CSV 文件中的文本转换成 Python 中的Decimal对象,然后再转换回来。像浮点一样,Decimal本身已经可以很好地转换成字符串,所以DecimalColumn真正需要做的唯一转换是在读取值时从字符串转换成Decimal。因为 Decimal 是为处理字符串而设计的,所以它与迄今为止显示的其他列一样简单:

img/330715_3_En_11_Figz_HTML.jpg

import decimal

class DecimalColumn(Column):
    """
    A column that contains data in the form of decimal values,
    represented in Python by decimal.Decimal.
    """

    def to_python(self, value):
        return decimal.Decimal(value)

然而,这个方法与其他类中的方法有一点不同。其他每一个都有额外的副作用,如果值不能被正确转换,就会产生一个ValueError,我们稍后可以用它来支持验证。Decimal确实在实例化期间进行了验证,但是它从decimal模块InvalidOperation中引发了一个异常。为了与其他人的行为相匹配,我们需要抓住这一点,并将其提升为ValueError:

img/330715_3_En_11_Figaa_HTML.jpg

import decimal

class DecimalColumn(Column):
    """
    A column that contains data in the form of decimal values,
    represented in Python by decimal.Decimal.
    """

    def to_python(self, value):
        try:
            return decimal.Decimal(value)
        except decimal.InvalidOperation as e:
            raise ValueError(str(e))

尽管DecimalColumn支持更专门化的数据类型,但它背后的代码仍然相当简单。相反,支持日期需要一些额外的复杂性。

日期列

日期在电子表格文档中也非常常见,它存储了从员工发薪日和假期到会议议程和出席情况的所有内容。像十进制值一样,日期需要使用单独的类来提供原生 Python 数据类型,但有一个显著的区别:日期没有普遍接受的字符串表示。有一些标准已经建立得相当好了,但是仍然有很多变化,从日期组件的位置到用于分隔它们的标点符号。

为了支持必要的灵活性,新的DateColumn将需要在实例化期间接受一个格式字符串,该字符串可用于解析文件中的值以及构造要存储在文件中的字符串。Python 日期已经使用了灵活的格式字符串语法, 5 ,所以没有必要专门为sheets发明一个新的。然而,为了在实例化期间指定格式,我们需要覆盖__init__():

img/330715_3_En_11_Figab_HTML.jpg

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

请注意,format 对象有一个默认值,这使得它是可选的。通常最好为字段属性提供这样的默认值,以便用户可以快速启动并运行。之所以选择这里使用的默认值,是因为它相当常见,并且它按照从最不具体到最具体的顺序(分别从年到日)排列值。这有助于减少我们在日期格式不同的文化中可能遇到的歧义。但是,因为目标是处理现有数据,所以特定的Row类总是可以用给定文件使用的任何格式来覆盖这种行为。

既然格式在DateColumn对象上可用,下一步,就像对其他对象一样,是创建一个to_python()方法。Python 的datetime对象接受日期的每个组成部分作为一个单独的参数,但是因为to_python()只获得一个字符串,我们将需要另一种方法来完成它。另一种形式是名为strptime()datetime类方法。

strptime()方法接受一个字符串值作为第一个参数,一个格式字符串作为第二个参数。然后根据格式字符串解析该值,并返回一个datetime对象。然而,我们实际上并不需要完整的datetime,所以我们也可以使用该对象的date()方法,只返回值的日期部分作为date对象:

img/330715_3_En_11_Figac_HTML.jpg

import datetime

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        return datetime.datetime.strptime(value, self.format).date()

注意

模块的名字和类的名字,这就是为什么它被写了两次。

然而,这里写的to_python()有一个微妙的问题。到目前为止,所有其他列类型都可以接受一个字符串和一个本机对象作为to_python()中的值,但是如果您传入一个date对象而不是一个字符串,strptime()将失败,并返回一个TypeError。为了在 Python 中构造一行并将其保存在文件中,我们需要能够在这里接受一个datetime对象,它将在以后保存时被转换为一个字符串。

因为to_python()应该返回一个本地对象,所以这是一个非常简单的任务。它只需要检查传入的值是否已经是一个date对象。如果是这样,to_python()可以简单地返回,而不做任何工作。否则,它可以继续进行转换:

img/330715_3_En_11_Figad_HTML.jpg

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """

    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        if isinstance(value, datetime.date):
            return value
        return datetime.datetime.strptime(value, self.format).date()

编写to_python()方法实际上是DateColumn类中最麻烦的部分。将现有的date值转换成字符串更加简单,因为有一个实例方法strftime()可以完成这项工作。它只接受一种格式并返回一个包含格式化值的字符串:

img/330715_3_En_11_Figae_HTML.jpg

import datetime

class DateColumn(Column):
    """
    A column that contains data in the form of dates,
    represented in Python by datetime.date.

    format
        A strptime()-style format string.
        See http://docs.python.org/library/datetime.html for details
    """
    def __init__(self, *args, format='%Y-%m-%d', **kwargs):
        super(DateColumn, self).__init__(*args, **kwargs)
        self.format = format

    def to_python(self, value):
        """
        Parse a string value according to self.format
        and return only the date portion.
        """
        if isinstance(value, datetime.date):
            return value
        return datetime.datetime.strptime(value, self.format).date()

    def to_string(self, value):
        """
        Format a date according to self.format and return that as a string.
        """
        return value.strftime(self.format)

小费

记住这两个方法名之间区别的一个有用的方法是,p代表“解析”,f代表“格式”

我们可以继续添加越来越多的字段,但是这里显示的内容涵盖了大多数 CSV 文件中的基本数据形式,以及在声明性框架中构建自己的字段属性所需的大多数技术。接下来,我们需要设置 CSV 功能,以便将这些数据类型应用到生活中。

回到 CSV

到目前为止,这一章是相当通用的,展示了可以应用于各种声明类框架的工具和技术。为了将它们投入实际使用,我们需要回到解析 CSV 文件的问题上来。本节中完成的大部分工作也适用于其他框架,但是将以特定于 CSV 的方式呈现。

首先要做的是看看 Python 自己的csv模块是如何工作的。完全重新发明轮子是没有意义的。理解现有的接口很重要,这样我们才能尽可能地匹配它。csv模块的功能由两种基本的对象类型提供:读取器和写入器。

读取器和写入器的配置方式相似。它们都接受一个文件参数、一个可选的方言和任意数量的关键字参数,这些参数指定了单独的方言参数来覆盖主方言。读取器和写入器之间的主要区别在于,读取器要求打开文件进行读访问,而写入器要求进行写访问。

对于读者来说,文件参数通常是一个file对象,但实际上可能是任何可迭代的对象,每次迭代产生一个字符串。因为csv模块还处理更复杂的换行符用法,比如在一个值中编码的换行符,所以你应该总是用参数newline="打开文件,以确保 Python 自己的换行符处理不会碍事。在下一个示例中,请确保运行该程序的目录中有 example.csv 文件:

img/330715_3_En_11_Figaf_HTML.jpg

>>> import csv

>>> reader = csv.reader(open('example.csv', newline="))

一旦被实例化用于特定的文件和方言,CSV reader 对象就有了一个极其简单的接口:它是一个可迭代的对象。遍历一个阅读器将产生 CSV 文件中的每一行,作为一个可以在csv模块之外使用的数据结构。标准的csv.reader为每一行产生一个值列表,因为它唯一知道的是每一个值在行中的位置。

一个更高级的选项是csv.DictReader,它在实例化过程中也接受一系列列名,这样每一行都可以作为一个字典生成。我们的框架甚至更进一步,生成一个对象,将文件中的每个值转换为原生 Python 数据类型,并作为属性提供。

相比之下,编写器对象稍微复杂一些。因为简单迭代只允许读取值,而不是写入值,所以编写人员依靠两种方法来完成必要的工作。第一个是writerow(),顾名思义,向文件中写出一行。它的伙伴writerows()接受一系列行,这些行将按照它们在序列中的顺序写入文件。

根据所使用的编写器类型,行的具体构成会有所不同。与阅读器一样,csv模块提供了一些不同的选项。标准的csv.writer为每一行接受一个简单的值序列,将每一个值放在它在列表中找到的位置。更复杂的DictWriter接受一个字典,它使用实例化期间传入的列名序列来确定每个值应该写在行中的什么位置。

使用我们的框架的接口应该看起来尽可能像这些标准阅读器和编写器的接口。一个sheets阅读器应该是一个 iterable 对象,它产生自定义类的实例,所有的列属性都在这个类中定义。同样,作者应该接受同一个类的实例。在这两种情况下,类定义中列属性的顺序将用于确定值的去向。

然而,读者和作者的一个关键因素是行对象的概念。到目前为止,我们还没有任何这样的 sheets 框架对象,所以我们需要创建一个。作为一个基于类的框架,sheets已经准备好构建一个可以表示行的对象。列和方言已经在一个类中定义了,所以创建对象的理想方式是简单地用一组值实例化该类。这将引入前面几节中描述的方言和列类,以便生成一个可用的对象。

实现这种行为的明显地方是__init__(),但是从那里开始事情变得有点棘手。第一个问题是如何接受将填充属性的值。因为我们还不知道任何特定的Row子类的布局,我们将不得不接受所有的参数并处理__init__()方法本身的需求。

检查参数

与任何函数一样,__init__()的参数可以按位置或按关键字传递,但是这个决定在这里有特殊的影响,因为对象可以用两种方式之一进行实例化。当从一个 CSV 文件实例化时,正如下一节将显示的,最简单的方法是按位置传递值。然而,当手动构建实例时,通过关键字传递值也非常方便。因此,最好接受所有位置和关键字参数,并在内部管理它们。

两种无效参数的情况在一开始就很明显:太多的位置参数和关键字参数与任何列名都不匹配。每种情况都需要单独的代码来支持它,但是它们都很容易使用。对于位置的情况,我们可以简单地根据列数检查参数的数量:

img/330715_3_En_11_Figag_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        if len(args) > len(self._dialect.columns):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(self._dialect.columns), len(args)))

这处理了传入太多位置参数的情况,使用了显式定义参数时 Python 会发出的相同错误消息。下一步是确保所有提供的关键字参数都与现有的列名匹配。这很容易测试,方法是循环遍历关键字参数名称,并检查每个名称是否也出现在列名列表中。

因为方言只存储列的列表,而不是列名的列表,所以在测试它们之前,最简单的方法是在这里创建一个新的列名列表。稍后添加到__init__()的额外代码也将使用这个新列表,所以最好现在就创建它:

img/330715_3_En_11_Figah_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

这处理了明显的情况,但是还有一种情况没有涉及到:关键字参数的目标列也有位置参数。为了解决这个问题,我们来看看 Python 本身的行为。当遇到按位置和按关键字传递的参数时,Python 会抛出一个TypeError,而不是被迫决定使用两个值中的哪一个:

img/330715_3_En_11_Figai_HTML.jpg

>>> def example(x):
...     return x
...
>>> example(1)
1
>>> example(x=1)
1
>>> example(1, x=1)
Traceback (most recent call last):
  ...

TypeError: example() got multiple values for keyword argument 'x'

提供我们自己的__init__()的相同行为比前面的例子要复杂一点,但是仍然相当简单。我们只需要查看每个位置参数,并检查是否有关键字参数匹配相应的列名。

对于这种情况,一个有用的快捷方式是在列名数组上使用一个切片,只获取与位置参数一样多的名称。这样,我们不必查看不必要的名称,并且消除了在循环中通过索引查找列名的单独步骤:

img/330715_3_En_11_Figaj_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for name in column_names[:len(args)]:
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)

检查完所有的参数后,__init__()可以确定没有提供无效的参数。从这里开始,我们可以使用这些参数来填充对象本身的值。

填充值

在对象上填充值实际上涉及到两个步骤。第一个是由于__init__()接受位置和关键字参数。通过提供这两个选项,我们现在有两个不同位置的争论:argskwargs。为了在一次传递中设置值,我们需要将它们组合成一个结构。

理想情况下,这个结构应该是一个字典,因为它结合了名称和值,所以我们需要将位置参数移动到已经由kwargs提供的字典中。为此,我们需要为每个按位置传入的值建立一个索引,并引用相应的列名,这样就可以将值赋给正确的名称。

上一节的最后一次检查已经提供了那个循环,所以我们可以重用那个块来给kwargs赋值。我们需要对循环进行的唯一更改是使用enumerate()来获取每一列的索引及其名称。然后,该索引可用于从args获取值:

img/330715_3_En_11_Figak_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

现在,kwargs已经将所有值传递给了构造函数,每个值都映射到了适当的列名。接下来,在将这些值分配给对象之前,我们需要将它们转换成适当的 Python 值。要做到这一点,我们需要实际的列对象,而不仅仅是我们到目前为止一直在使用的名称列表。

还有一个小问题需要考虑。遍历列可以得到为该类定义的所有列,但是kwargs只包含传递给对象的值。我们需要决定如何处理没有可用值的列。当从 CSV 文件中提取数据时,这通常不是问题,因为文件中的每一行每一列都应该有一个条目。但是,当在 Python 中填充一个对象以便以后保存在文件中时,在实例化对象之后分配属性通常是有用的。

因此,这里最灵活的方法是简单地将None分配给任何没有值的列。检查必填字段可以作为一个单独的步骤来执行,当我们开始验证其他内容的字段时。现在,分配None就可以了:

img/330715_3_En_11_Figal_HTML.jpg

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

        # Now populate the actual values on the object
        for column in self._dialect.columns:
            try:
                value = column.to_python(kwargs[column.name])
            except KeyError:
                # No value was provided
                value = None
            setattr(self, column.name, value)

有了这个功能,您就可以看到Row类自己运行了。它现在能够管理一组列,接受值作为输入,在加载时将它们转换为 Python 对象,并将这些值分配给适当的属性:

img/330715_3_En_11_Figam_HTML.jpg

>>> import sheets
>>> class Author(sheets.Row):
...     name = sheets.StringColumn()
...     birthdate = sheets.DateColumn()
...     age = sheets.IntegerColumn()
...
>>> ex = Author('Marty Alchin', birthdate='1981-12-17', age="28")
>>> ex.name
'Marty Alchin'
>>> ex.birthdate
datetime.date(1981, 12, 17)
>>> ex.age

28

现在我们终于可以实现与 CSV 文件交互的代码了。

读者

直接使用csv模块,通过实例化一个类并传入一个文件和必要的配置选项来获得一个读取器。sheets 框架允许每个定制的Row类直接在类上指定所有的列和方言参数,所以现在包含了我们需要的一切。与csv的直接类比是将一个文件和一个Row类传递给一个函数,然后该函数返回一个能够读取该文件的 reader 对象。

这种方法的问题在于,它需要任何想要使用读取器的代码来导入sheets模块,以便获得创建读取器对象的函数。相反,我们可以通过提供一个可以完成必要工作的类方法,只使用Row类本身。然后,方法需要接受的唯一参数是要读取的文件。为了匹配现有的csv命名约定,我们将调用这个新方法reader()

为了像标准阅读器一样工作,我们自己的reader()需要返回一个 iterable 对象,为每次迭代生成一行。这是一个需要满足的简单要求,甚至不需要任何新对象就可以完成。记住,当第一次调用生成器函数时,它们实际上会返回一个 iterable 对象。然后在循环的每次迭代中执行生成器的主体,这是支持 CSV 阅读器的理想方式。

为了从 CSV 文件中获取值,reader()可以依赖现有的csv模块自己的阅读器功能。标准的csv.reader为文件中的每一行返回一个列表,不管实际值是什么意思,也不管它们的名字应该是什么。因为 row 类已经可以处理存储在序列(如列表)中的参数,所以将两者绑定在一起非常简单:

img/330715_3_En_11_Figan_HTML.jpg

import csv

class Row(metaclass=RowMeta):
    def __init__(self, *args, **kwargs):
        # First, make sure the arguments make sense
        column_names = [column.name for column in self._dialect.columns]

        if len(args) > len(column_names):
            msg = "__init__() takes at most %d arguments (%d given)"
            raise TypeError(msg % (len(column_names), len(args)))

        for name in kwargs:
            if name not in column_names:
                msg = "__init__() got an unexpected keyword argument '%s'"
                raise TypeError(msg % name)

        for i, name in enumerate(column_names[:len(args)]):
            if name in kwargs:
                msg = "__init__() got multiple values for keyword argument '%s'"
                raise TypeError(msg % name)
            kwargs[name] = args[i]

        # Now populate the actual values on the object
        for column in self._dialect.columns:
            try:
                value = column.to_python(kwargs[column.name])
            except KeyError:
                # No value was provided
                value = None
            setattr(self, column.name, value)

    @classmethod
        def reader(cls, file):
           for values in csv.reader(file):
           yield cls(*values)

然而,这忽略了从 CSV 文件中读取的一个重要方面。在文件中存储值的方式有很多变化,您可能需要指定一些选项来控制文件的处理方式。早些时候,Dialect类提供了在Row类上指定这些选项的方法,所以现在我们需要在对csv.reader()的调用中传递一些选项。特别是,这些选项存储在方言的csv_dialect属性中:

img/330715_3_En_11_Figao_HTML.jpg

@classmethod
    def reader(cls, file):
        for values in csv.reader(file, **cls._dialect.csv_dialect):
            yield cls(*values)

这包括了csv模块已经知道的选项,但是记住我们自己的Dialect类允许另一个选项来指示文件是否有标题行。为了在阅读器中支持该特性,我们需要添加一些额外的代码,如果方言指示第一行将是标题,则跳过该行:

img/330715_3_En_11_Figap_HTML.jpg

@classmethod
    def reader(cls, file):
        csv_reader = csv.reader(file, **cls._dialect.csv_dialect)

        # Skip the first row if it's a header
        if cls._dialect.has_header_row:
            csv_reader.__next__()

        for values in csv_reader:
            yield cls(*values)

因为阅读器需要提供的只是一个为每个对象生成一行的 iterable,所以这个方法现在做了它需要做的一切。然而,这并不是很超前的想法。因为我们正在构建一个以后可能需要改进的框架,所以至少考虑未来的扩展总是一个好主意。

一种更灵活的方法是创建一个新的可迭代类来完成同样的工作,而不是仅仅依赖于一个生成器函数。正如我们将在下一节看到的,作者也需要一个单独的类,所以构建这个新的 iterable 将创建一对更容易理解的类。首先,reader()方法变得简单多了:

img/330715_3_En_11_Figaq_HTML.jpg

    @classmethod
      def reader(cls, file):
           return Reader(cls, file)

这将所有真正的工作委托给了一个新的Reader类,该类必须实现__iter__()__next__()才能起到迭代器的作用。然而,有一些东西需要先存储在__init__()中,包括可以创建每个实例的 row 类和实际读取文件的csv.reader对象:

img/330715_3_En_11_Figar_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

__iter__()方法很容易支持,因为Reader本身就是迭代器。因此,唯一需要做的就是归还self:

img/330715_3_En_11_Figas_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

    def __iter__(self):
        return self

因为每次迭代都会调用__next__(),所以对于返回单个行对象这一明显的任务来说,它的逻辑会简单一些。它需要做的就是在csv.reader的迭代器上调用__next__(),将值传递给存储在__init__()中的 row 类:

img/330715_3_En_11_Figat_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)

    def __iter__(self):
        return self

    def __next__(self):
        return self.row_cls(*self.csv_reader.__next__())

你会从第五章中想起,当手动构建迭代器时,你必须小心引发StopIteration异常以避免无限循环。在这种情况下,我们不必直接这么做,因为csv.reader会自己这么做。一旦记录用完,我们自己的__next__()方法只需要让StopIteration过去而不被抓住。

要实现的最后一个特性是标题行,它稍微复杂一些。在前面展示的生成器函数中,很容易在进入真正的循环之前处理标题行。作为一个手动迭代器,我们必须单独管理它,因为对于每条记录,__next__()将从开始被调用。

为此,我们需要保留一个布尔属性来指示我们是否仍然需要跳过标题行。开始时,该属性将与方言的has_header_row属性相同,但是一旦跳过了标题行,就需要重置该属性,以便__next__()可以每隔一段时间生成一条有效记录:

img/330715_3_En_11_Figau_HTML.jpg

class Reader:
    def __init__(self, row_cls, file):
        self.row_cls = row_cls
        self.csv_reader = csv.reader(file, **row_cls._dialect.csv_dialect)
        self.skip_header_row = row_cls._dialect.has_header_row

    def __iter__(self):
        return self

    def __next__(self):
        # Skip the first row if it's a header
        if self.skip_header_row:
            self.csv_reader.__next__()
            self.skip_header_row = False

        return self.row_cls(*self.csv_reader.__next__())

您可以通过提供一个简单的 CSV 文件并读入它来测试它。考虑一个包含粗略目录的文件,其中一列是章节号,另一列是章节标题。下面是如何编写一个Row来表示该文件并解析其内容:

img/330715_3_En_11_Figav_HTML.jpg

>>> import sheets
>>> class Content(sheets.Row):
...     chapter = sheets.IntegerColumn()
...     title = sheets.StringColumn()
...
>>> file = open('contents.csv', newline=“)
>>> for entry in Content.reader(file):
...     print('%s: %s' % (entry.chapter, entry.title))
...
1: Principles and Philosophy
2: Advanced Basics
3: Functions
4: Classes
5: Protocols
6: Object Management
7: Strings
8: Documentation
9: Testing
10: Distribution

11: Sheets: A CSV Framework

这就完成了从 CSV 文件中的行到单个 Python 对象的转换。因为Content类实例中的每一行,您还可以定义您喜欢的任何其他方法,并在处理来自文件的条目时使用这些方法。对于框架的另一面,我们需要一个编写器将这些对象移回 CSV 文件。

作家

与阅读器不同,CSV 编写器的接口需要一些实例方法,因此实现稍微复杂一些。这一次,一个生成器方法不能解决这个问题,所以我们需要添加一个新的类来管理文件写入行为。我们仍然可以依靠csv模块自身的行为来完成大部分繁重的工作,所以这个新类只需要管理sheets框架的附加特性。

界面的第一部分很简单。为了反映读取器的可用性,应该可以从Row子类的方法中访问写入器。这个方法也将接受一个 file 对象,但是这一次它必须返回一个新的对象,而不是立即对那个文件做任何事情。这使得这个writer()方法的实现本身很简单:

img/330715_3_En_11_Figaw_HTML.jpg

    @classmethod
        def writer(cls, file):
            return Writer(file, cls._dialect)

注意

SheetWriter不能只处理文件,因为它与Row是分开的,否则无法访问任何方言选项。

然而,这显然没有做任何有用的事情,所以主要任务是创建并填充SheetWriter类。满足 writer 接口有两个必要的方法,writerow()writerows()。前者负责获取单个对象并将一行写到文件中,而后者接受一系列对象,将它们作为文件中单独的一行写出。

在开始使用这些方法之前,Writer需要一些基本的初始化。它需要访问的第一个显而易见的信息是类的列列表。除此之外,它还需要 CSV 选项,但这些选项只在使用csv模块本身创建编写器时才需要,就像 reader 一样。最后,它需要访问csv不知道自己的选项has_header_row:

img/330715_3_En_11_Figax_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

在进入最重要的writerow()方法之前,请注意标题行选项在分配给类时实际上被命名为needs_header_row。这允许writerow()使用该属性作为标志来指示标题行是否仍然需要被写入。如果一开始不需要任何行,那么它从False开始,但是如果它以True的形式出现,那么一旦文件头实际写入文件,它就可以翻转到False

为了写标题行本身,我们也可以使用csv.writer来写值行。csv模块不关心文件的整体结构是什么,所以我们可以传入一行标题值,它将像所有其他行一样被处理。这些头值来自类中每一列的title属性,但是我们可以使用字符串的title()方法使它们更友好一些:

img/330715_3_En_11_Figay_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False

有了这个标题,writerow()就可以继续写传递到方法中的实际行了。支持头部的代码已经列出了大部分需要做的事情。唯一的区别是,list comprehension 需要从传入的 row 对象中获取相应的值,而不是获取每一列的标题:

img/330715_3_En_11_Figaz_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False
        values = [getattr(row, column.name) for column in self.columns]
        self._writer.writerow(values)

最后,作者还需要一个writerows()方法,该方法可以获取一系列对象并将它们作为单独的行写出。困难的工作已经完成,所以所有的writerows()需要做的就是为每个传入序列的对象调用writerow():

img/330715_3_En_11_Figba_HTML.jpg

class Writer:
    def __init__(self, file, dialect):
        self.columns = dialect.columns
        self._writer = csv.writer(file, dialect.csv_dialect)
        self.needs_header_row = dialect.has_header_row

    def writerow(self, row):
        if self.needs_header_row:
            values = [column.title.title() for column in self.columns]
            self._writer.writerow(values)
            self.needs_header_row = False
        values = [getattr(row, column.name) for column in self.columns]
        self._writer.writerow(values)

    def writerows(self, rows):
        for row in rows:
            self.writerow(row)

有了 CSV 读取器和写入器,sheets框架就完成了。您可以添加更多的列类来支持额外的数据类型,或者根据您的具体需求添加更多的方言选项,但是框架总体上是完整的。您可以通过读取现有文件并将其写回新文件来验证全部功能。只要所有的方言参数都与文件的结构匹配,这两个文件的内容将是相同的:

img/330715_3_En_11_Figbb_HTML.jpg

>>> import sheets
>>> class Content(sheets.Row):
...     chapter = sheets.IntegerColumn()
...     title = sheets.StringColumn()
...
>>> input = open('contents.csv', newline=“)
>>> reader = Content.reader(input)
>>> output = open('compare.csv', 'w', newline=“)
>>> writer = Content.writer(output)
>>> writer.writerows(reader)
>>> input.close()
>>> output.close()
>>> open('contents.csv').read() == open('compare.csv').read()

True

带着它

在本章中,你已经看到了如何使用 Python 提供的许多工具来规划、构建和定制一个框架。原本需要重复多次的复杂任务已经简化为可重用和可扩展的工具。然而,这只是本书中的技术如何结合起来完成如此复杂的任务的一个例子。剩下的就看你的了。

Footnotes 1

参见姜戈, http://propython.com/django

  2

参见“Python SQL 工具包和对象关系映射器”, http://propython.com/sqlalchemy

  3

http://propython.com/twisted见 扭曲矩阵实验室】。

  4

参见【CSV 文件读写】 http://propython.com/csv-module

  5

参见“strftime()和 strptime()行为”, http://propython.com/datetime-formatting