在本章中,我们将介绍以下配方:
- 使用 pathlib 处理文件名
- 使用上下文管理器读取和写入文件
- 在保留以前版本的同时替换文件
- 使用 CSV 模块读取分隔文件
- 使用正则表达式读取复杂格式
- 读取 JSON 文档
- 读取 XML 文档
- 阅读 HTML 文档
- 将 CSV 从 DictReader 升级到 namedtuple reader
- 将 CSV 从 DictReader 升级到命名空间读取器
- 使用多个上下文读写文件
术语文件具有多种含义:
-
The operating system ( OS ) uses a file as a way to organize bytes of data. The bytes might represent an image, some sound samples, words, or even an executable program. All of the wildly different kinds of content are reduced to a collection of bytes. Application software makes sense of the bytes.
有两种常见的操作系统文件:
- 块文件存在于磁盘或固态驱动器(SSD等设备上。这些文件可以以字节块的形式读取。操作系统可以在任何时候查找文件中的任何特定字节。
- 字符文件是一种管理设备(如网络连接或连接到计算机的键盘)的方法。文件被视为单个字节流,这些字节到达看似随机的时间点。在字节流中无法向前或向后搜索。
-
单词file还定义了 Python 运行时使用的数据结构。Python 文件抽象封装了各种 OS 文件实现。打开文件时,Python 抽象、OS 实现和磁盘或其他设备上的底层字节集合之间存在绑定。
-
文件也可以解释为 Python 对象的集合。从这个角度来看,文件的字节表示 Python 对象,如字符串或数字。文本字符串文件非常常见,易于使用。Unicode 字符通常使用 UTF-8 编码方案编码为字节,但也有许多替代方案。Python 提供了
shelve
和pickle
等模块,将更复杂的 Python 对象编码为字节。
通常,我们会讨论对象是如何序列化的。将对象写入文件时,Python 对象状态信息将转换为一系列字节。反序列化是从字节恢复 Python 对象的反向过程。我们也可以将这种想法称为状态表示,因为我们通常将每个单独对象的状态序列化为独立于类定义的状态。
当我们处理文件中的数据时,通常需要进行两个区分:
- 数据的物理格式:这回答了一个基本问题,即文件中的字节对 Python 数据结构进行编码。字节可能是 Unicode 文本。文本可以表示逗号分隔值(CSV)或 JSON 文档。物理格式通常由 Python 库处理。
- 数据的逻辑布局:布局查看数据中各种 CSV 列或 JSON 字段的详细信息。在某些情况下,可能会标记列,或者可能存在必须按位置解释的数据。这通常是我们应用程序的责任。
物理格式和逻辑布局对于解释文件上的数据至关重要。我们将研究一些使用不同物理格式的方法。我们还将探讨如何将程序与逻辑布局的某些方面分离。
大多数操作系统使用分层路径来标识文件。下面是一个文件名示例:
/Users/slott/Documents/Writing/Python Cookbook/code
此完整路径名包含以下元素:
- 前导
/
表示名称是绝对的。它从文件系统的根开始。在 Windows 中,名称前面可以有一个额外的字母,例如C:
,用于区分每个存储设备上的文件系统。Linux 和 Mac OS X 将所有设备视为一个单一的大型文件系统。 - 诸如
Users
、slott
、Documents
、Writing
、Python Cookbook
、code
等名称代表文件系统的目录(或文件夹)。必须有一个顶级的Users
目录。它必须包含slott
子目录。对于路径中的每个名称都是如此。 - 在 Windows 中,操作系统使用
\
分隔路径上的项目。Python 使用/
。Python 标准/
优雅地转换为 Windows 路径分隔符字符;我们通常可以忽略窗口\
。
无法判断名称code
代表什么类型的对象。文件系统对象有很多种。名称code
可能是命名其他文件的目录。它可以是普通数据文件,也可以是指向流设备的链接。还有其他目录信息显示这是什么类型的文件系统对象。
没有前导/
的路径相对于当前工作目录。在 Mac OS X 和 Linux 中,cd
命令设置当前工作目录。在 Windows 中,chdir
命令执行此任务。当前工作目录是操作系统登录会话的一项功能。它是通过外壳可见的。
我们如何以独立于特定操作系统的方式使用路径名?我们如何简化常见操作,使其尽可能统一?
区分两个概念很重要:
- 标识文件的路径
- 文件的内容
路径提供目录名和最终文件名的可选序列。它可以通过文件扩展名提供有关文件内容的一些信息。目录包括文件名、有关文件创建时间、文件所有者、权限、文件大小的信息以及其他详细信息。文件的内容与目录信息和名称分开。
通常,文件名有一个后缀,可以提供物理格式的提示。以.csv
结尾的文件可能是一个文本文件,可以解释为数据的行和列。名称和物理格式之间的绑定不是绝对的。文件后缀只是一个提示,可能是错误的。
一个文件的内容可能有多个名称。多个路径可以链接到单个文件。使用 link(ln
命令创建为文件内容提供附加名称的目录项。Windows 使用mklink
。这被称为硬链接,因为它是名称和内容之间的低级连接。
除了硬链接,我们还可以有软链接或符号链接(或连接点)。软链接是一种不同类型的文件,该链接很容易被视为对另一个文件的引用。操作系统的 GUI 显示可能会将这些图标显示为不同的图标,并将其称为别名或快捷方式以明确说明。
在 Python 中,pathlib
模块处理所有与路径相关的处理。该模块对路径进行了若干区分:
- 可能引用或不引用实际文件的纯路径
- 解析并引用实际文件的具体路径
这种区别允许我们为应用程序可能创建或引用的文件创建纯路径。我们还可以为操作系统上实际存在的文件创建具体路径。应用程序可以解析纯路径以创建具体路径。
pathlib
模块还区分了 Linux 路径对象和 Windows 路径对象。这种区别很少需要;大多数时候,我们不想关心路径的操作系统级细节。使用pathlib
的一个重要原因是,无论底层操作系统如何,我们都希望处理是相同的。我们可能想要使用PureLinuxPath
对象的情况很少。
本节中的所有迷你食谱将利用以下内容:
>>> from pathlib import Path
我们很少需要pathlib
中的任何其他类定义。
我们假设argparse
用于收集文件或目录名。有关argparse
的更多信息,请参见第 5 章用户输入和输出中的使用 argparse 获取命令行输入配方。我们将使用options
变量,该变量具有配方使用的input
文件名或目录名。
出于演示目的,通过提供以下Namespace
对象显示模拟参数解析:
>>> from argparse import Namespace
>>> options = Namespace(
... input='/path/to/some/file.csv',
... file1='/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py',
... file2='/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py',
... )
此options
对象有三个模拟参数值。input
值是一个纯路径:它不一定反映实际文件。file1
和file2
值反映了作者计算机上存在的具体路径。此对象的行为与argparse
模块创建的选项相同。
我们将展示一些常见的路径名操作,作为单独的迷你配方。这将包括以下操作:
- 从输入文件名生成输出文件名
- 生成多个同级输出文件
- 创建目录和多个文件
- 比较文件日期以查看哪个较新
- 删除文件
- 查找与给定模式匹配的所有文件
通过更改输入后缀,执行以下步骤以生成输出文件名:
-
Create the
Path
object from the input filename string. ThePath
class will properly parse the string to determine the elements of the path:>>> input_path = Path(options.input) >>> input_path PosixPath('/path/to/some/file.csv')
在本例中,显示
PosixPath
类是因为作者正在使用 Mac OS X。在 Windows 计算机上,该类将为WindowsPath
。 -
Create the output
Path
object using thewith_suffix()
method:>>> output_path = input_path.with_suffix('.out') >>> output_path PosixPath('/path/to/some/file.out')
所有文件名解析都由
Path
类无缝处理。with_suffix()
方法使我们不必手动解析文件名的文本。
执行以下步骤以生成具有不同名称的多个同级输出文件:
-
Create a
Path
object from the input filename string. ThePath
class will properly parse the string to determine the elements of the path:>>> input_path = Path(options.input) >>> input_path PosixPath('/path/to/some/file.csv')
在本例中,显示
PosixPath
类是因为作者使用 Linux。在 Windows 计算机上,类将是WindowsPath
。 -
从文件名中提取父目录和词干。茎是没有后缀的名称:
>>> input_directory = input_path.parent >>> input_stem = input_path.stem
-
生成所需的输出名称。在本例中,我们将在文件名后追加
_pass
。file.csv
的输入文件将产生file_pass.csv
:>>> output_stem_pass = input_stem+"_pass" >>> output_stem_pass 'file_pass'
的输出
-
Build the complete
Path
object:>>> output_path = (input_directory / output_stem_pass).with_suffix('.csv') >>> output_path PosixPath('/path/to/some/file_pass.csv')
/
操作符从path
组件组装一条新路径。我们需要把它放在括号中,以确保它首先被执行并创建一个新的Path
对象。input_directory
变量有父Path
对象,output_stem_pass
是一个简单的字符串。在使用/
操作符组装新路径后,使用with_suffix()
方法确保使用特定后缀。
以下步骤用于创建目录和多个文件:
-
Create the
Path
object from the input filename string. ThePath
class will properly parse the string to determine the elements of the path:>>> input_path = Path(options.input) >>> input_path PosixPath('/path/to/some/file.csv')
在本例中,显示
PosixPath
类是因为作者使用 Linux。在 Windows 计算机上,类将是WindowsPath
。 -
为输出目录创建
Path
对象。在本例中,我们将创建一个output
目录,作为与源文件>>> output_parent = input_path.parent / "output" >>> output_parent PosixPath('/path/to/some/output')
具有相同父目录的子目录
-
Create the output filename using the output
Path
object. In this example, the output directory will contain a file that has the same name as the input with a different suffix:>>> input_stem = input_path.stem >>> output_path = (output_parent / input_stem).with_suffix('.src')
我们已经使用了
/
操作符从父Path
组装了一个新的Path
对象,并基于文件名的词干组装了一个字符串。一旦创建了一个Path
对象,我们就可以使用with_suffix()
方法为文件设置所需的后缀。
以下是通过比较更新的文件日期来查看更新的文件日期的步骤:
-
从输入文件名字符串创建
Path
对象。Path
类将正确解析字符串以确定路径的元素:>>> file1_path = Path(options.file1) >>> file1_path PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py') >>> file2_path = Path(options.file2) >>> file2_path PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py')
-
使用每个
Path
对象的stat()
方法获取文件的时间戳。此方法返回一个stat
对象,在该stat
对象中,该对象的st_mtime
属性为文件>>> file1_path.stat().st_mtime 1464460057.0 >>> file2_path.stat().st_mtime 1464527877.0
提供了最近的修改时间
这些值是以秒为单位测量的时间戳。我们可以很容易地比较这两个值,看看哪一个较新。
如果我们想要一个对人们敏感的时间戳,我们可以使用datetime
模块从以下内容创建一个合适的datetime
对象:
>>> import datetime
>>> mtime_1 = file1_path.stat().st_mtime
>>> datetime.datetime.fromtimestamp(mtime_1)
datetime.datetime(2016, 5, 28, 14, 27, 37)
我们可以使用strftime()
方法格式化datetime
对象,也可以使用isoformat()
方法提供标准化显示。注意,该时间将本地时区偏移隐式地应用于 OS 时间戳;根据操作系统配置,笔记本电脑可能与创建它的服务器显示的时间不同,因为它们位于不同的时区。
Linux 中删除文件的术语是取消链接。由于一个文件可能有多个链接,因此在删除所有链接之前,不会删除实际数据:
-
从输入文件名字符串创建
Path
对象。Path
类将正确解析字符串以确定路径的元素:>>> input_path = Path(options.input) >>> input_path PosixPath('/path/to/some/file.csv')
-
使用此
Path
对象的unlink()
方法删除目录项。如果这是数据的最后一个目录条目,那么操作系统可以回收空间:>>> try: ... input_path.unlink() ... except FileNotFoundError as ex: ... print("File already deleted") File already deleted
如果文件不存在,则引发一个FileNotFoundError
。在某些情况下,需要使用pass
语句来消除此异常。在其他情况下,警告消息可能很重要。丢失的文件也可能代表严重错误。
此外,我们可以使用Path
对象的rename()
方法重命名文件。我们可以使用symlink_to()
方法创建新的软链接。要创建操作系统级硬链接,我们需要使用os.link()
功能。
以下是查找与给定模式匹配的所有文件的步骤:
-
从输入目录名创建
Path
对象。Path
类将正确解析字符串以确定路径的元素:>>> directory_path = Path(options.file1).parent >>> directory_path PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code')
-
使用
Path
对象的glob()
方法查找与给定模式匹配的所有文件。默认情况下,这不会递归遍历整个目录树:>>> list(directory_path.glob("ch08_r*.py")) [PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r01.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r02.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r06.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r07.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r08.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r09.py'), PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/ch08_r10.py')]
在操作系统内部,路径是一系列目录(文件夹是对目录的描述)。在诸如/Users/slott/Documents/writing
的名称中,根目录/
包含名为Users
的目录。它包含一个子目录slott
,其中包含Documents
,其中包含writing
。
在某些情况下,使用简单的字符串表示法来总结从根目录到最终目标目录的导航。字符串表示法;然而,这使得许多类型的路径操作成为复杂的字符串解析问题。
Path
类定义简化了纯路径上的许多操作。纯Path
可能反映也可能不反映实际的文件系统资源。Path
上的操作包括以下示例:
- 提取父目录以及所有封闭目录名的序列。
- 提取最终名称、最终名称的词干和最终名称的后缀。
- 用新后缀替换后缀,或用新名称替换整个名称。
- 将字符串转换为
Path
。并将Path
转换为字符串。许多操作系统函数和 Python 部分更喜欢使用文件名字符串。 - 使用
/
操作符从一个与字符串连接的现有Path
构建一个新的Path
对象。
具体的Path
表示实际的文件系统资源。对于具体的Paths
,我们可以对目录信息进行一些额外的操作:
- 确定这是什么类型的目录项:普通文件、目录、链接、套接字、命名管道(或 fifo)、块设备或字符设备。
- 获取目录详细信息,包括时间戳、权限、所有权、大小等信息。我们也可以修改这些东西。
- 我们可以取消链接(或删除)目录项。
我们可能想对文件的目录项执行的任何操作都可以通过pathlib
模块完成。少数例外情况是os
或os.path
模块的一部分。
当我们在本章剩余部分中查看其他与文件相关的方法时,我们将使用Path
对象来命名文件。目的是避免尝试使用字符串表示路径。
pathlib
模块对 Linux 纯Path
对象和 Windows 纯Path
对象进行了细微区分。大多数时候,我们并不关心路径的操作系统级细节。
有两种情况可以帮助为特定操作系统生成纯路径:
- 如果我们在 Windows 笔记本电脑上进行开发,但在 Linux 服务器上部署 web 服务,则可能需要使用
PureLinuxPath
。这允许我们在 Windows 开发机器上编写测试用例,以反映 Linux 服务器上的实际预期用途。 - 如果我们在 Mac OS X(或 Linux)笔记本电脑上进行开发,但只部署到 Windows 服务器,则可能需要使用
PureWindowsPath
。
我们可能会有这样的情况:
>>> from pathlib import PureWindowsPath
>>> home_path = PureWindowsPath(r'C:\Users\slott')
>>> name_path = home_path / 'filename.ini'
>>> name_path
PureWindowsPath('C:/Users/slott/filename.ini')
>>> str(name_path)
'C:\\Users\\slott\\filename.ini'
请注意,/
字符在显示WindowsPath
对象时从 Windows 规范化为 Python 符号。使用str()
函数检索适用于 Windows 操作系统的路径字符串。
如果我们尝试使用泛型Path
类,我们将得到一个适合用户环境的实现,这可能不是 Windows。通过使用PureWindowsPath
,我们绕过了到用户实际操作系统的映射。
- 在替换文件同时保留先前版本配方中,我们将了解如何利用
Path
的功能创建临时文件,然后重命名临时文件以替换原始文件 - 在第 5 章中使用 argparse 获取命令行输入配方用户输入和输出中,我们将了解一种非常常见的获取初始字符串的方法,该字符串将用于创建
Path
对象
许多程序将访问外部资源,如数据库连接、网络连接和操作系统文件。对于一个可靠、行为良好的程序来说,可靠、干净地释放所有外部纠缠是很重要的。
引发异常并最终崩溃的程序仍然可以正确地释放资源。这包括关闭文件并确保所有缓冲数据都正确写入文件。
这对于长时间运行的服务器尤其重要。web 服务器可以打开和关闭许多文件。如果服务器没有正确关闭每个文件,那么数据对象可能会留在内存中,从而减少可用于进行中的 web 服务的空间。工作记忆的丧失似乎是一种缓慢的泄漏。最终,服务器需要重新启动,从而降低可用性。
我们如何确保资源得到适当的获取和释放?我们如何避免资源泄漏?
昂贵而重要的资源的一个常见示例是外部文件。一个已经打开以供写入的文件也是一个宝贵的资源;毕竟,我们运行程序以创建文件形式的有用输出。Python 应用程序必须干净地释放与文件关联的 OS 级资源。我们希望确保无论应用程序内部发生什么情况,都会刷新缓冲区并正确关闭文件。
当我们使用上下文管理器时,我们可以确保应用程序使用的文件得到正确处理。特别是,即使在处理过程中引发异常,文件也将始终关闭。
例如,我们将使用一个脚本来收集有关目录中文件的一些基本信息。这可用于检测文件更改,该技术通常用于在文件被替换时触发处理。
我们将编写一个摘要文件,其中包含文件名、修改日期、大小以及根据文件中的字节计算的校验和。然后,我们可以检查目录并将其与摘要文件中的前一个状态进行比较。单个文件详细信息的描述可通过以下功能准备:
from types import SimpleNamespace
import datetime
from hashlib import md5
def file_facts(path):
return SimpleNamespace(
name = str(path),
modified = datetime.datetime.fromtimestamp(
path.stat().st_mtime).isoformat(),
size = path.stat().st_size,
checksum = md5(path.read_bytes()).hexdigest()
)
此函数从path
参数中给定的Path
对象获取相对文件名。我们也可以使用resolve()
方法来获取绝对路径名。Path
对象的stat()
方法返回许多 OS 状态值。状态的st_mtime
值为上次修改时间。表达式path.stat().st_mtime
获取文件的修改时间。用于创建一个完整的datetime
对象。然后,isoformat()
方法提供修改时间的标准化显示。
path.stat().st_size
的值是文件的当前大小。path.read_bytes()
的值是文件中的所有字节,这些字节被传递给md5
类,以使用 MD5 算法创建校验和。生成的md5
对象的hexdigest()
函数为我们提供了一个足够敏感的值,可以检测文件中的任何单字节更改。
我们希望将此应用于目录中的多个文件。例如,如果目录被使用,文件被频繁写入,那么我们的分析程序在试图读取由单独进程写入的文件时可能会因 I/O 异常而崩溃。
我们将使用上下文管理器来确保程序即使在极少数情况下崩溃也能提供良好的输出。
-
我们将处理文件路径,因此导入
Path
类from pathlib import Path
非常重要
-
创建一个标识输出文件的
Path
:summary_path = Path('summary.dat')
-
The
with
statement creates thefile
object, and assigns it to a variable,summary_file
. It also uses thisfile
object as the context manager:with summary_path.open('w') as summary_file:
我们现在可以使用
summary_file
变量作为输出文件。无论with
语句中出现什么异常,文件都将正确关闭,所有操作系统资源都将释放。
以下语句将当前工作目录中的文件信息写入打开的摘要文件。这些在with
语句中缩进:
base = Path(".")
for member in base.glob("*.py"):
print(file_facts(member), file=summary_file)
这将为当前工作目录创建一个Path
,并将对象保存在base
变量中。Path
对象的glob()
方法将生成与给定模式匹配的所有文件名。前面显示的file_facts()
函数生成一个包含有用信息的名称空间对象。我们可以将每个摘要打印到summary_file
。
我们省略了将事实转换成更有用的符号。如果数据以 JSON 表示法序列化,则可以稍微简化后续处理。
当with
语句结束时,文件将被关闭。无论是否出现任何异常,都会发生这种情况。
上下文管理器对象和with
语句一起工作来管理有价值的资源。在这种情况下,文件连接是一种相对昂贵的资源,因为它将 OS 资源与应用程序绑定在一起。它也很珍贵,因为它是脚本的有用输出。
当我们写入with x:
时,对象x
是上下文管理器。上下文管理器对象响应两种方法。这两个方法由所提供对象上的with
语句调用。重大事件如下:
x.__enter__()
在上下文开头进行评估。x.__exit__(*details)
在上下文结束时进行评估。无论上下文中可能出现的任何异常,__exit__()
都是有保证的。异常详细信息提供给__exit__()
方法。如果出现异常,上下文管理器可能希望采取不同的行为。
文件对象和其他几种类型的对象设计用于此对象管理器协议。
以下是描述如何使用上下文管理器的事件序列:
- 评估
summary_path.open('w')
以创建文件对象。这将保存到summary_file
。 - 随着上下文的启动,评估
summary_file.__enter__()
。 - 在
with
语句上下文中进行处理。这将向给定文件写入几行。 - 在
with
语句末尾,评估summary_file.__exit__()
。这将关闭输出文件,并释放所有操作系统资源。 - 如果在
with
语句中引发异常且未处理,则在文件正确关闭后重新引发该异常。
with
语句自动处理文件关闭操作。它们总是被执行,即使出现异常。这一保证对于防止资源泄漏至关重要。
有些人喜欢对这个词总是吹毛求疵:他们喜欢寻找上下文管理器无法正常工作的极少数情况。例如,整个 Python 运行时环境崩溃的可能性很小;这将使所有语言保证失效。如果 Python 上下文管理器没有正确关闭该文件,操作系统将关闭该文件,但最终的数据缓冲区可能会丢失。更遥远的可能性是整个操作系统崩溃,硬件停止,或者计算机在僵尸世界末日中被摧毁;在这些情况下,上下文管理器也不会关闭文件。
许多数据库连接和网络连接也可用作上下文管理器。上下文管理器保证正确关闭连接并释放资源。
我们也可以对输入文件使用上下文管理器。最佳实践是对所有文件操作使用上下文管理器。本章中的大多数食谱将使用文件和上下文管理器。
在极少数情况下,我们需要向对象添加上下文管理功能。contextlib
包含一个函数closing()
,该函数将调用对象的close()
方法。
我们可以使用它包装缺少适当上下文管理器功能的数据库连接:
from contextlib import closing
with closing(some_database()) as database:
process(database)
这假设some_database()
函数创建到数据库的连接。此连接不能直接用作上下文管理器。通过将连接包装在closing()
函数中,我们添加了必要的功能,使其成为一个适当的连接管理器对象,以便确保数据库已正确关闭。
- 有关多个上下文的更多信息,请参阅使用多个上下文读取和写入文件配方
我们可以利用pathlib
的强大功能来支持各种文件名操作。在使用 pathlib 处理文件名的配方中,我们了解了一些管理目录、文件名和文件后缀的最常用技术。
一个常见的文件处理要求是以故障安全的方式创建输出文件。也就是说,无论应用程序如何或在何处失败,应用程序都应保留任何以前的输出文件。
考虑下面的情景:
- 在t时间0有一个昨天使用
long_complex.py
应用程序的有效output.csv
文件。 - 在时间t1我们开始运行
long_complex.py
应用程序。它开始覆盖output.csv
文件。预计在时间t3正常完成。 - 在时间t2时,应用程序崩溃。部分
output.csv
文件无效。更糟糕的是,从时间t0起的有效文件也不可用,因为它已被覆盖。
显然,我们可以备份文件。这将引入额外的处理步骤。我们可以做得更好。创建故障安全文件的好方法是什么?
故障保护文件输出通常意味着我们不会覆盖上一个文件。相反,应用程序将使用临时名称创建一个新文件。如果文件创建成功,则可以使用重命名操作替换旧文件。
我们的目标是以这样一种方式创建文件,即在重命名之前的任何时候,崩溃都会使原始文件保持原位。在重命名之后的任何时候,新文件都已就位且有效。
有几种方法可以做到这一点。我们将显示使用三个单独文件的变体:
- 最终将被覆盖的输出文件:
output.csv
。 - 文件的临时版本:
output.csv.tmp
。命名此文件有多种约定。有时会在文件名上添加额外的字符,如~
或#
,以表明它是一个临时的工作文件。有时它会在/tmp
文件系统中。 - 文件的上一版本:
name.out.old
。作为最终输出的一部分,任何先前的.old
文件都将被删除。
-
导入
Path
类:>>> from pathlib import Path
-
For demonstration purposes, we'll mock the argument parsing by providing the following
Namespace
object:>>> from argparse import Namespace >>> options = Namespace( ... target='/Users/slott/Documents/Writing/Python Cookbook/code/output.csv' ... )
我们已经为
target
命令行参数提供了一个模拟值。此options
对象的行为类似于argparse
模块创建的选项。 -
为所需的输出文件创建纯
Path
。这个文件还不存在,这就是为什么这是一个纯路径:>>> output_path = Path(options.target) >>> output_path PosixPath('/Users/slott/Documents/Writing/Python Cookbook/code/output.csv')
-
为临时输出文件创建纯
Path
。这将用于创建输出:>>> output_temp_path = output_path.with_suffix('.csv.tmp')
-
Write content to the temporary file. This is of course the heart of the application. It's often quite complex. For this example, we've shortened it to writing just one literal string:
>>> output_temp_path.write_text("Heading1,Heading2\r\n355,113\r\n")
此处的任何故障都不会影响原始输出文件;原始文件没有被碰过。
-
Remove any prior
.old file
:>>> output_old_path = output_path.with_suffix('.csv.old') >>> try: ... output_old_path.unlink() ... except FileNotFoundError as ex: ... pass # No previous file
此时的任何故障都不会影响原始输出文件。
-
If there's an existing file, rename it to become the
.old file
:>>> output_path.rename(output_old_path)
在此之后出现的任何故障都将使
.old
文件保持原位。此额外文件可以作为恢复过程的一部分重命名。 -
将临时文件重命名为新的输出文件:
>>> output_temp_path.rename(output_path)
-
此时,通过重命名临时文件,该文件已被覆盖。将保留一个
.old
文件,以防需要将处理回滚到以前的状态。
此过程涉及三个独立的操作系统操作、一个取消链接和两个重命名。这导致需要使用.old
文件来恢复以前良好的状态。
下面是显示各种文件状态的时间线。我们已将内容标记为第 1 版(以前的内容)和第 2 版(修订内容):
| **时间** | **操作** | **.csv.old** | **.csv** | **.csv.tmp** | | *t*0 | | 版本 0 | 版本 1 | | | *t*1 | 写 | 版本 0 | 版本 1 | 生产中的 | | *t*2 | 关 | 版本 0 | 版本 1 | 版本 2 | | *t*3 | 取消链接`.csv.old` | | 版本 1 | 版本 2 | | *t*4 | 将`.csv`重命名为`.csv.old` | 版本 1 | | 版本 2 | | *t*5 | 将`.csv.tmp`重命名为`.csv` | 版本 1 | 版本 2 | |虽然存在多个失败的机会,但对于哪个文件是有效的,这一点并不含糊:
- 如果有
.csv
文件,则为当前有效文件 - 如果没有
.csv
文件,则.csv.old
文件为备份副本,可用于恢复
由于这些操作都不涉及实际复制文件,因此它们都非常快速和可靠。
在许多情况下,输出文件包括根据时间戳选择性地创建目录。这也可以由pathlib
模块优雅地处理。例如,我们可能有一个归档目录,将旧文件放入其中:
archive_path = Path("/path/to/archive")
我们可能希望创建带有日期戳的子目录,用于保存临时或工作文件:
import datetime
today = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
然后,我们可以执行以下操作来定义工作目录:
working_path = archive_path / today
working_path.mkdir(parents=True, exists_ok=True)
mkdir()
方法将创建所需的目录。包括确保所有父目录也将被创建的parents=True
参数。这在应用程序第一次执行时非常方便。exists_ok=True
非常方便,因此可以重用现有目录,而不会引发异常。
parents=True
不是默认值。默认值为parents=False
,当父目录不存在时,应用程序将崩溃,因为所需文件不存在。
同样,exists_ok=True
也不是默认值。默认情况下,如果目录存在,则引发FileExistsError
异常。包括在目录存在时使操作静音的选项。
此外,有时使用tempfile
模块来创建临时文件也是合适的。此模块可以创建保证唯一的文件名。这允许复杂的服务器进程创建临时文件,而不考虑文件名冲突。
- 在使用 pathlib 处理文件名的方法中,我们了解了
Path
类的基础知识 - 在第 11 章、测试中,我们将了解一些编写单元测试的技术,这些技术可以确保部分单元测试正常运行
一种常用的数据格式是 CSV。我们可以很容易地将其推广到将逗号视为许多候选分隔符字符中的一个。我们可能有一个 CSV 文件,它使用|
字符作为数据列之间的分隔符。这种泛化使 CSV 文件特别强大。
我们如何处理各种 CSV 格式中的数据?
文件内容的摘要称为模式。必须区分模式的两个方面:
- 文件的物理格式:对于 CSV,这意味着文件包含文本。文本被组织成行和列。将有一个或多个行分隔符字符;还有一个列分隔符。许多电子表格产品将使用
,
作为列分隔符,\r\n
字符序列作为行分隔符。不过,也可以使用其他格式,而且很容易更改分隔列和行的标点符号。标点符号的特定组合称为 CSV 方言。 - 文件中数据的逻辑布局:这是存在的数据列的顺序。处理 CSV 文件中的逻辑布局有几种常见情况:
- 该文件有一行标题。这非常理想,并且非常适合 CSV 模块的工作方式。最好的标题是正确的 Python 变量名。
- 文件没有标题,但列位置是固定的。在这种情况下,我们可以在打开文件时对其施加标题。
- 如果文件没有标题,并且列位置不固定,这通常是一个严重的问题。这不容易解决。需要额外的模式信息;例如,列定义的单独列表可以使文件可用。
- 该文件有多行标题。在这种情况下,我们必须编写特殊处理来跳过这些行。我们还必须用 Python 中更有用的东西来替换复杂的标题。
- 更困难的情况是,文件不是正确的第一范式(1NF)。在 1NF 中,每一行独立于所有其他行。当文件不是这种正常形式时,我们需要添加一个生成器函数来将数据重新排列到 1NF 中。参见第 4 章中的切分列表配方、第 8 章中的内置数据结构–列表、集合、dict和使用堆叠生成器表达式配方、功能和反应式编程特性用于规范化数据结构的其他方法。
我们将查看一个相对简单的 CSV 文件,其中包含从帆船日志中记录的一些实时数据。这是waypoints.csv
文件。数据如下:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
此数据有四列,需要重新格式化以创建更有用的信息。
-
导入
csv
模块和Path
类:import csv
-
从
pathlib
导入PathExamine
数据,确认以下特征:- 列分隔符:
','
为默认值。 - 行分隔符:
'\r\n'
在 Windows 和 Linux 中都广泛使用。这可能是 Excel 的一个功能,但它非常常见。Python 的通用换行符特性意味着 Linux 标准'\n'
可以像行分隔符一样工作。 - 单行标题的存在。如果不存在,可单独提供此信息。
- 列分隔符:
-
创建一个标识文件的
Path
对象:data_path = Path('waypoints.csv')
-
Use the
Path
object to open the file in awith
statement:with data_path.open() as data_file:
有关 with 语句的更多信息,请参阅使用上下文管理器读取和写入文件配方。
-
从打开的文件对象创建 CSV 读取器。这在
with
语句中缩进:data_reader = csv.DictReader(data_file)
-
读取(并处理)各种数据行。这在
with
语句中正确缩进。对于本例,我们将只打印它们:for row in data_reader: print(row)
输出是一系列字典,如下所示:
{'date': '2012-11-27',
'lat': '32.8321666666667',
'lon': '-79.9338333333333',
'time': '09:15:00'}
由于行已转换为字典,列键的顺序与原始顺序不同。如果我们使用pprint
模块中的pprint()
,则按键倾向于按字母顺序排序。我们现在可以参考row['date']
来处理数据。使用列名比按位置引用列更具描述性:row[0]
很难理解。
csv
模块处理物理格式工作,将行彼此分离,并分离每行中的列。默认规则确保每个输入行被视为一个单独的行,列由","
分隔。
当我们需要使用列分隔符作为数据的一部分时会发生什么?我们可能有这样的数据:
lan,lon,date,time,notes
32.832,-79.934,2012-11-27,09:15:00,"breezy, rainy"
31.671,-80.933,2012-11-28,00:00:00,"blowing ""like stink"""
notes
列在第一行有数据,其中包括","
列分隔符。CSV 规则允许列的值用引号括起来。默认情况下,引用字符为"
。在这些引用字符中,将忽略列分隔符和行分隔符。
为了将引号字符嵌入到带引号的字符串中,它被加倍。第二个示例行显示了在引号列中使用引号字符时,如何通过将引号字符加倍来对值"blowing "like stink""
进行编码。这些引用规则意味着 CSV 文件可以表示字符的任意组合,包括行分隔符和列分隔符。
CSV 文件中的值始终是字符串。像7331
这样的字符串值在我们看来可能像一个数字,但在csv
模块处理时它只是文本。这使得处理变得简单和统一,但对人类用户来说可能会很尴尬。
一些 CSV 数据是从数据库或 web 服务器等软件导出的。这些数据往往是最容易处理的,因为不同的行往往组织一致。
当从手动准备的电子表格中保存数据时,数据可能会揭示桌面软件内部数据显示规则的怪癖。例如,一列数据在桌面软件上显示为日期,但在 CSV 文件中显示为简单的浮点数,这种情况非常常见。
对于日期作为数字的问题,有两种解决方案。一种是在源电子表格中添加一列,以将日期正确格式化为字符串。理想情况下,这是使用 ISO 规则完成的,因此日期以 YYYY-MM-DD 格式表示。另一种解决方案是将电子表格日期识别为某个划时代日期后的秒数。划时代的日期略有不同,但通常是 1900 年 1 月 1 日或 1904 年 1 月 1 日。
正如我们在组合 map 和 reduce 转换配方中所看到的,通常有一条处理管道,包括源数据的清理和转换。在这个特定的示例中,没有需要删除的额外行。但是,每个列都需要转换为更有用的内容。
为了将数据转换成更有用的形式,我们将使用两部分的设计。首先,我们将定义一个行级清理函数。在本例中,我们将通过添加其他类似列的值来更新行级字典对象:
import datetime
def clean_row(source_row):
source_row['lat_n']= float(source_row['lat'])
source_row['lon_n']= float(source_row['lon'])
source_row['ts_date']= datetime.datetime.strptime(
source_row['date'],'%Y-%m-%d').date()
source_row['ts_time']= datetime.datetime.strptime(
source_row['time'],'%H:%M:%S').time()
source_row['timestamp']= datetime.datetime.combine(
source_row['ts_date'],
source_row['ts_time']
)
return source_row
我们创建了新的列值lat_n
和lon_n
,它们具有正确的浮点值而不是字符串。我们还解析了日期和时间值以创建datetime.date
和datetime.time
对象。我们还将日期和时间组合为一个有用的值,即timestamp
列的值。
一旦我们有了一个用于清理和丰富数据的行级函数,我们就可以将该函数映射到数据源中的每一行。我们可以使用map(clean_row, reader)
或编写一个体现此处理循环的函数:
def cleanse(reader):
for row in reader:
yield clean_row(row)
这可用于从每行提供更有用的数据:
with data_path.open() as data_file:
data_reader = csv.DictReader(data_file)
clean_data_reader = cleanse(data_reader)
for row in clean_data_reader:
pprint(row)
我们已经注入了cleanse()
函数来创建一个非常小的转换规则堆栈。堆栈以data_reader
开头,其中只有一个其他项。这是一个良好的开端。当应用软件扩展以进行更多计算时,堆栈将扩展。
这些经过清理和充实的行如下所示:
{'date': '2012-11-27',
'lat': '32.8321666666667',
'lat_n': 32.8321666666667,
'lon': '-79.9338333333333',
'lon_n': -79.9338333333333,
'time': '09:15:00',
'timestamp': datetime.datetime(2012, 11, 27, 9, 15),
'ts_date': datetime.date(2012, 11, 27),
'ts_time': datetime.time(9, 15)}
我们添加了lat_n
和lon_n
等列,它们具有正确的数值而不是字符串。我们还添加了timestamp
,它有一个完整的日期时间值,可以用于简单计算航路点之间经过的时间。
- 有关处理管道或堆栈的更多信息,请参见组合映射和减少转换配方
- 参见第 4 章中的切分列表配方,内置数据结构第 8 章中的切分列表配方,功能性和反应性编程特性中的使用堆叠生成器表达式对和进行切分有关处理不在正确 1NF 中的 CSV 文件的详细信息,请参阅**
有许多文件格式缺乏 CSV 文件的优雅规则性。一种很难解析的常见文件格式是 web 服务器日志文件。这些文件往往具有复杂的数据,没有单个分隔符或一致的引用规则。
当我们查看第 8 章、功能性和反应性编程特性中的编写生成器函数的配方中的简化日志文件时,我们看到行如下所示:
[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One
[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging
[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong
此文件中使用了多种标点符号。csv
模块无法处理这种复杂性。
我们如何以 CSV 文件的优雅简单性处理此类数据?我们能把这些不规则的行转换成更规则的数据结构吗?
解析具有复杂结构的文件通常需要编写一个函数,其行为有点像csv
模块中的reader()
函数。在某些情况下,创建一个行为类似于DictReader
类的小类稍微容易一些。
阅读器的核心功能是将一行文本转换为单个字段值的 dict 或 tuple 的函数。这项工作通常可以通过re
包完成。
在开始之前,我们需要开发(并调试)正则表达式来正确解析输入文件的每一行。有关这方面的更多信息,请参见第 1 章中的正则表达式字符串解析配方、数字、字符串和元组。
对于本例,我们将使用以下代码。我们将使用一系列正则表达式为行的各个元素定义一个模式字符串:
>>> import re
>>> pattern_text = (r'\[(\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]'
... '\s+(\w+)'
... '\s+in'
... '\s+([\w_\.]+):'
... '\s+(.*)')
>>> pattern = re.compile(pattern_text)
日期时间戳是各种数字、连字符、冒号和逗号;被[
和]
包围。我们不得不使用\[
和\]
来逃避正则表达式中[
和]
的正常含义。日期戳后面是一个严重性级别,它是一次字符运行。字符in
可以忽略;没有用于捕获匹配数据的()
。模块名称为字母字符序列,由字符类\w
汇总,也包括_
和.
。模块名称后面还有一个额外的:
字符,也可以忽略。最后,有一条消息一直延伸到这行的末尾。我们将有趣的数据字符串包装在()
中,以捕获每个字符串作为正则表达式处理的一部分。
请注意,我们还包括了\s+
序列,以安静地跳过任意数量的空格字符。样本数据似乎都使用单个空格作为分隔符。然而,当吸收空白时,使用\s+
似乎是一种更为普遍的方法,因为它允许额外的空格。
以下是此模式的工作原理:
>>> sample_data = '[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One'
>>> match = pattern.match(sample_data)
>>> match.groups()
('2016-05-08 11:08:18,651', 'INFO', 'ch09_r09', 'Sample Message One')
我们提供了一系列样本数据。匹配对象match
有一个groups()
方法返回每个感兴趣的字段。我们可以通过对每个捕获使用(?P<name>...)
而不是简单地使用(...)
将其编入一个带有命名字段的字典。
这个配方有两个部分,分别为一行定义解析函数和为每一行输入使用解析函数。
执行以下步骤以定义解析函数:
-
Define the compiled regular expression object:
import re pattern_text = (r'\[(?P<date>\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]' '\s+(?P<level>\w+)' '\s+in\s+(?P<module>[\w_\.]+):' '\s+(?P<message>.*)') pattern = re.compile(pattern_text)
我们使用了
(?P<name>...)
正则表达式构造来为捕获的每个组提供名称。结果字典将与csv.DictReader
的结果相同。 -
定义一个接受一行文本作为参数的函数:
def log_parser(source_line):
-
应用正则表达式以创建匹配对象。我们已将其分配给
match
变量:match = pattern.match(source_line)
-
如果匹配对象为
None
,则线与图案不匹配。这一行可以悄悄地跳过。在某些应用程序中,应该以某种方式记录它,以提供对调试或增强应用程序有用的信息。为无法解析的输入行引发异常也是有意义的:if match is None: raise ValueError( "Unexpected input {0!r}".format(source_line))
-
Return a useful data structure with the various pieces of data from this input line:
return match.groupdict()
此函数可用于解析每行输入。文本将转换为具有字段名和值的字典。
-
导入
csv
模块和Path
类:import csv
-
从
pathlib
导入PathCreate
开始,标识文件的Path
对象:data_path = Path('sample.log')
-
Use the
Path
object to open the file in awith
statement:with data_path.open() as data_file:
有关
with
语句的更多信息,请参阅使用上下文管理器读取和写入文件方法。 -
从打开的文件对象
data_file
创建日志文件解析器。在本例中,我们将使用map()
将解析器应用于源文件data_reader = map(log_parser, data_file)
中的每一行
-
读取(并处理)各种数据行。对于本例,我们将只打印它们:
for row in data_reader: pprint(row)
输出是一系列字典,如下所示:
{'date': '2016-05-08 11:08:18,651',
'level': 'INFO',
'message': 'Sample Message One',
'module': 'ch09_r09'}
{'date': '2016-05-08 11:08:18,651',
'level': 'DEBUG',
'message': 'Debugging',
'module': 'ch09_r09'}
{'date': '2016-05-08 11:08:18,652',
'level': 'WARNING',
'message': 'Something might have gone wrong',
'module': 'ch09_r09'}
我们可以对这些词典进行比对一行原始文本更有意义的处理。这些允许我们按严重性级别过滤数据,或根据提供消息的模块创建Counter
。
此日志文件是第一个标准格式的典型文件。数据被组织成表示独立实体或事件的行。每一行都有数量一致的属性或列,每一列都有原子数据或无法进一步分解的数据。与 CSV 文件不同,该格式需要一个复杂的正则表达式来解析。
在我们的日志文件示例中,时间戳有许多单独的元素年、月、日、小时、分钟、秒和毫秒,但是进一步分解时间戳没有什么价值。将其作为单个datetime
对象使用,并从该对象派生细节(如一天中的小时),而不是将单个字段组合成新的复合数据段,这样会更有帮助。
在复杂的日志处理应用程序中,可能有多种消息字段。可能需要使用单独的模式解析这些消息类型。当我们需要这样做时,它揭示了日志中的各行在属性的格式和数量上不一致,打破了第一个正常形式的假设。
在数据不一致的情况下,我们必须创建更复杂的解析器。这可能包括复杂的过滤规则,以分离可能出现在 web 服务器日志文件中的各种信息。它可能涉及解析行的一部分,以确定必须使用哪个正则表达式来解析行的其余部分。
我们依赖于使用map()
高阶函数。这将log_parse()
函数应用于源文件的每一行。这种直接的简单性提供了一些保证,即创建的数据对象的数量将与日志文件中的行数精确匹配。
我们通常遵循 cvs 模块配方中读取分隔文件的设计模式,因此读取复杂日志与读取简单 CSV 文件几乎相同。事实上,我们可以看到主要区别在于一行代码:
data_reader = csv.DictReader(data_file)
与之相比:
data_reader = map(log_parser, data_file)
这种并行结构允许我们跨多种输入文件格式重用分析函数。这允许我们创建一个工具库,可以在许多数据源上使用。
读取非常复杂的文件时,最常见的操作之一是将其重写为更易于处理的格式。我们通常希望以 CSV 格式保存数据,以供以后处理。
其中一些类似于使用多个上下文读取和写入文件配方,该配方还显示了多个开放上下文。我们将从一个文件读取数据,然后写入另一个文件。
文件写入过程如下所示:
import csv
data_path = Path('sample.log')
target_path = data_path.with_suffix('.csv')
with target_path.open('w', newline='') as target_file:
writer = csv.DictWriter(
target_file,
['date', 'level', 'module', 'message']
)
writer.writeheader()
with data_path.open() as data_file:
reader = map(log_parser, data_file)
writer.writerows(reader)
此脚本的第一部分为给定文件定义 CSV 编写器。输出文件target_path
的路径基于输入名称data_path
。后缀从原始文件名的后缀更改为.csv
。
打开此文件时,使用newline=''
选项关闭换行符。这允许csv.DictWriter
类插入适合所需 CSV 方言的换行符。
创建一个DictWriter
对象来写入给定文件。提供了一系列列标题。这些键必须与用于将每一行写入文件的键相匹配。我们可以看到这些标题与生成数据的正则表达式的(?P<name>...)
部分相匹配。
writeheader()
方法将列名写入输出的第一行。由于提供了列名,因此读取文件稍微容易一些。CSV 文件的第一行可以是一种显式的模式定义,显示存在哪些数据。
如前面的配方所示,打开源文件。由于csv
模块编写器的工作方式,我们可以为编写器的writerows()
方法提供reader()
生成器功能。writerows()
方法将消耗reader()
函数产生的所有数据。这将依次消耗打开的文件生成的所有行。
我们不需要编写任何显式的for
语句来确保所有的输入行都得到处理。writerows()
功能提供了这种保证。
输出文件如下所示:
date,level,module,message
"2016-05-08 11:08:18,651",INFO,ch09_r09,Sample Message One
"2016-05-08 11:08:18,651",DEBUG,ch09_r09,Debugging
"2016-05-08 11:08:18,652",WARNING,ch09_r09,Something might have gone wrong
该文件已从相当复杂的输入格式转换为更简单的 CSV 格式。
- 在第 8 章功能性和反应性编程特性中使用收益声明配方编写生成器函数显示了该日志格式的其他处理
- 在使用 CSV 模块配方读取分隔文件的过程中,我们将研究此通用设计模式的其他应用程序
- 在将 CSV 从 Dictreader 升级到 namedtuple reader和将 CSV 从 Dictreader 升级到 namespace reader配方中,我们将看到更复杂的处理技术
用于序列化数据的 JSON 符号非常流行。详见http://json.org 。Python 包含json
模块,用于在此符号中序列化和反序列化数据。
JSON 文档被 JavaScript 应用程序广泛使用。使用 JSON 表示法的文档在基于 Python 的服务器和基于 JavaScript 的客户端之间交换数据是很常见的。应用程序堆栈的这两层通过通过 HTTP 协议发送的 JSON 文档进行通信。有趣的是,数据持久层还可以使用 HTTP 协议和 JSON 表示法。
我们如何使用json
模块在 Python 中解析 JSON 数据?
我们在race_result.json
中收集了一些帆船比赛的结果。此文件包含有关团队、腿以及各团队完成比赛腿的顺序的信息。
在许多情况下,当船只没有启动、没有完成或被取消比赛资格时,存在空值。在这些情况下,终点位置的得分比最后一个位置高一分。如果有七条船,那么该队得八分。这是一个沉重的惩罚。
数据具有以下架构。整个文档中有两个字段:
legs
:显示起始端口和结束端口的字符串数组。teams
:包含每个团队详细信息的对象数组。在每个团队对象中,有几个数据字段:name
:字符串团队名称。position
:带位置的整数和空值数组。此数组中项目的顺序与 legs 数组中项目的顺序匹配。
数据如下:
{
"teams": [
{
"name": "Abu Dhabi Ocean Racing",
"position": [
1,
3,
2,
2,
1,
2,
5,
3,
5
]
},
...
],
"legs": [
"ALICANTE - CAPE TOWN",
"CAPE TOWN - ABU DHABI",
"ABU DHABI - SANYA",
"SANYA - AUCKLAND",
"AUCKLAND - ITAJA\u00cd",
"ITAJA\u00cd - NEWPORT",
"NEWPORT - LISBON",
"LISBON - LORIENT",
"LORIENT - GOTHENBURG"
]
}
我们只展示了一队。在这场比赛中一共有七支队伍。
JSON 格式的数据看起来像一个包含列表的 Python 字典。Python 语法和 JSON 语法之间的这种重叠可以被认为是一种令人高兴的巧合:它使从 JSON 源文档构建的 Python 数据结构更容易可视化。
并非所有 JSON 结构都只是 Python 对象。有趣的是,JSON 文档有一个空项,它映射到 Python 的None
对象。意思相似,但语法不同。
另外,其中一个字符串包含 Unicode 转义序列\u00cd
,而不是实际的 Unicode 字符Í。这是一种常用技术,用于对 128 个 ASCII 字符以外的字符进行编码。
-
导入
json
模块:>>> import json
-
Define a
Path
object that identifies the file to be processed:>>> from pathlib import Path >>> source_path = Path("code/race_result.json")
json
模块目前不直接与Path
对象一起工作。因此,我们将把内容作为一大块文本读取,并处理该文本对象。 -
通过解析 JSON 文档创建 Python 对象:
>>> document = json.loads(source_path.read_text())
我们已经使用source_path.read_text()
读取了由Path
命名的文件。我们将此字符串提供给json.loads()
函数进行解析。
解析文档以创建 Python 字典后,我们可以看到各种片段。例如,字段teams
包含每个团队的所有结果。这是一个数组,该数组中的项 0 是第一个团队。
每个团队的数据将是一个带有两个键的字典:name
和position
。我们可以组合各种键以获得第一个团队的名称:
>>> document['teams'][0]['name']
'Abu Dhabi Ocean Racing'
我们可以在legs
区域内查看比赛每一站的名称:
>>> document['legs'][5]
'ITAJAÍ - NEWPORT'
请注意,JSON 源文件包含一个'\u00cd'
Unicode 转义序列。这是正确解析的,Unicode 输出显示了正确的Í字符。
JSON 文档是 JavaScript 对象表示法中的数据结构。JavaScript 程序可以简单地解析文档。其他语言必须做更多的工作才能将 JSON 转换为本机数据结构。
JSON 文档包含三种结构:
- 映射到 Python 字典的对象:JSON 的语法类似于 Python:
{"key": "value"}
。与 Python 不同,JSON 只使用"
作为字符串引号。JSON 表示法不允许在字典值的末尾添加额外的值。除此之外,这两种符号是相似的。 - 映射到 Python 列表的数组:JSON 语法使用
[item, ...]
,看起来像 Python。JSON 不允许在数组值的末尾添加额外的字符。 - 原语值:有五类值:字符串、数字、
true
、false
和null
。字符串包含在"
中,并使用各种\escape
序列,这些序列类似于 Python。数字遵循浮点值的规则。其他三个值是简单文本;这些并行 Python 的True
、False
和None
文本。
没有提供任何其他类型的数据。这意味着 Python 程序必须将复杂的 Python 对象转换为更简单的表示形式,以便可以用 JSON 表示法序列化它们。
相反,我们经常应用额外的转换来从简化的 JSON 表示重构复杂的 Python 对象。json
模块有一些地方,我们可以对简单结构应用额外的处理,以创建更复杂的 Python 对象。
一个文件通常包含一个 JSON 文档。该标准没有提供在单个文件中编码多个文档的简单方法。例如,如果我们想分析 web 日志,JSON 可能不是保存大量信息的最佳表示法。
我们通常还需要解决另外两个问题:
- 序列化复杂对象以便将其写入文件
- 从文件读取的文本中反序列化复杂对象
当我们将 Python 对象的状态表示为文本字符字符串时,我们已经序列化了该对象。许多 Python 对象需要保存在文件中或传输到另一个进程。这些类型的传输需要对象状态的表示。我们将分别研究序列化和反序列化。
我们还可以从 Python 数据结构创建 JSON 文档。由于 Python 非常复杂和灵活,我们可以轻松创建无法用 JSON 表示的 Python 数据结构。
如果我们创建的 Python 对象仅限于简单的dict
、list
、str
、int
、float
、bool
和None
值,那么 JSON 的序列化效果最好。如果我们小心,我们可以构建快速序列化的对象,并且可以被许多用不同语言编写的程序广泛使用。
这些类型的值都不涉及 Pythonsets
或其他类定义。这意味着我们经常被迫将复杂的 Python 对象转换为字典,以在 JSON 文档中表示它们。
例如,假设我们已经分析了一些数据并创建了一个结果Counter
对象:
>>> import random
>>> random.seed(1)
>>> from collections import Counter
>>> colors = (["red"]*18)+(["black"]*18)+(["green"]*2)
>>> data = Counter(random.choice(colors) for _ in range(100))
Because this data is - effectively - a dict, we can serialie this very easily into JSON:
>>> print(json.dumps(data, sort_keys=True, indent=2))
{
"black": 53,
"green": 7,
"red": 40
}
我们以 JSON 表示法转储了数据,并将键按顺序排序。这确保了一致的输出。两个缩进将显示每个{}
对象和每个[]
数组的视觉缩进,以便更容易看到文档的结构。
我们可以通过一个相对简单的操作将其写入文件:
output_path = Path("some_path.json")
output_path.write_text(
json.dumps(data, sort_keys=True, indent=2))
当我们重新阅读此文档时,我们将不会从 JSON 加载操作中获得一个Counter
对象。我们只会得到一个字典实例。这是 JSON 简化为非常简单的值的结果。
datetime.datetime
对象是一种不容易序列化的常用数据结构。下面是我们尝试时发生的情况:
>>> import datetime
>>> example_date = datetime.datetime(2014, 6, 7, 8, 9, 10)
>>> document = {'date': example_date}
我们创建了一个只有一个字段的简单文档。该字段的值是一个datetime
实例。当我们尝试用 JSON 序列化时会发生什么?
>>> json.dumps(document)
Traceback (most recent call last):
...
TypeError: datetime.datetime(2014, 6, 7, 8, 9, 10) is not JSON serializable
这表明无法序列化的对象将引发TypeError
异常。可以通过以下两种方法之一避免此异常。我们可以在构建文档之前转换数据,也可以在 JSON 序列化过程中添加一个钩子。
一种技术是在将datetime
对象序列化为 JSON 之前将其转换为字符串:
>>> document_converted = {'date': example_date.isoformat()}
>>> json.dumps(document_converted)
'{"date": "2014-06-07T08:09:10"}'
这使用日期的 ISO 格式来创建可以序列化的字符串。读取此数据的应用程序可以将字符串转换回datetime
对象。
序列化复杂数据的另一种技术是提供在序列化过程中自动使用的默认函数。此函数必须将复杂对象转换为可以安全序列化的对象。它通常会创建一个包含字符串和数值的简单字典。它还可能创建一个简单的字符串值:
>>> def default_date(object):
... if isinstance(object, datetime.datetime):
... return example_date.isoformat()
... return object
我们已经定义了一个函数default_date()
,它将对datetime
对象应用特殊的转换规则。这些将被转换成字符串对象,这些对象可以通过json.dumps()
函数进行序列化。
我们使用default
参数将此函数提供给dumps()
函数,如下所示:
>>> document = {'date': example_date}
>>> print(
... json.dumps(document, default=default_date, indent=2))
{
"date": "2014-06-07T08:09:10"
}
在任何给定的应用程序中,我们都需要扩展此函数,以处理我们可能希望以 JSON 表示法序列化的任何更复杂的 Python 对象。如果存在大量非常复杂的数据结构,我们通常需要一种更通用的解决方案,而不是将每个对象仔细地转换为可序列化的对象。有许多设计模式用于包括类型信息以及对象状态的序列化细节。
当反序列化 JSON 以创建 Python 对象时,可以使用另一个钩子将 JSON 字典中的数据转换为更复杂的 Python 对象。这被称为object_hook
,在json.loads()
处理过程中用于检查每个复杂对象,看看是否应该从该 dict 中创建其他内容。
我们提供的函数要么创建一个更复杂的 Python 对象,要么干脆不使用 dict:
>>> def as_date(object):
... if 'date' in object:
... return datetime.datetime.strptime(
... object['date'], '%Y-%m-%dT%H:%M:%S')
... return object
此函数将检查解码的每个对象,以查看该对象是否具有名为date
的字段。如果是,则整个对象的值将替换为一个datetime
对象。
我们为json.loads()
函数提供如下函数:
>>> source= '''{"date": "2014-06-07T08:09:10"}'''
>>> json.loads(source, object_hook=as_date)
datetime.datetime(2014, 6, 7, 8, 9, 10)
这将解析一个非常小的 JSON 文档,该文档满足包含日期的条件。生成的 Python 对象是根据 JSON 序列化中的字符串值构建的。
在更大的背景下,这个处理日期的特殊示例并不理想。如果存在一个指示日期对象的'date'
字段,则使用此as_date()
函数反序列化更复杂的对象时可能会出现问题。
一种更通用的方法是寻找一些独特的、非 Python 的东西,比如'$date'
。另一项功能将确认特殊指示器是该对象的唯一键。当满足这两个标准时,就可以对对象进行特殊处理。
我们可能还希望设计应用程序类,以提供其他方法来帮助序列化。一个类可能包含一个to_json()
方法,该方法将以统一的方式序列化对象。此方法可能提供类信息。它可以避免序列化任何派生属性或计算属性。类似地,我们可能需要提供一个静态from_json()
方法,该方法可用于确定给定字典对象是否实际上是给定类的实例。
- 阅读 HTML 文档配方将展示我们如何从 HTML 源准备这些数据
XML 标记语言广泛用于组织数据。详见http://www.w3.org/TR/REC-xml/ 。Python 包含许多用于解析 XML 文档的库。
XML 被称为标记语言,因为感兴趣的内容用定义数据结构的<tag>
和</tag>
结构标记。整个文件包括内容和 XML 标记文本。
因为标记与文本混合在一起,所以必须使用一些额外的语法规则。为了在数据中包含<
字符,我们将使用 XML 字符实体引用以避免混淆。我们使用<
可以在文本中包含<
。同样地,使用>
代替>
,&
代替&
,并且"
还用于在属性值中嵌入"
。
那么,文档将包含以下项目:
<team><name>Team SCA</name><position>...</position></team>
大多数 XML 处理允许在 XML 中添加\n
和空格字符,以使结构更加明显:
<team>
<name>Team SCA</name>
<position>...</position>
</team>
通常,内容由标记包围。整个文档形成一个大型嵌套容器集合。从另一个角度看,文档形成了一个包含所有其他标记及其嵌入内容的根标记的树。在标记之间,在本例中有完全空白的附加内容将被忽略。
用正则表达式解析它是非常非常困难的。我们需要更复杂的解析器来处理嵌套语法。
有两个二进制库可用于解析 XML-SAX 和 Expat。Python 包括xml.sax
和xml.parsers.expat
来利用这两个模块。
除此之外,xml.etree
包中还有一套非常复杂的工具。我们将重点介绍如何使用ElementTree
模块解析和分析 XML 文档。
我们如何使用xml.etree
模块在 Python 中解析 XML 数据?
我们在race_result.xml
中收集了一些帆船比赛的结果。此文件包含有关团队、腿以及各个团队完成每个腿的顺序的信息。
在许多情况下,当船只没有启动、没有完成或被取消比赛资格时,存在空值。在这些情况下,分数将比船只数量多 1。如果有七条船,那么该队得八分。这是一个沉重的惩罚。
根标签是<results>
文档。这具有以下架构:
<legs>
标签包含单个<leg>
标签,用于命名比赛的每一站。分支名称在文本中同时包含起始端口和结束端口。<teams>
标签包含许多<team>
标签,其中包含每个团队的详细信息。每个团队都有内部标记结构化的数据:<name>
标记包含团队名称。<position>
标签包含多个<leg>
标签,其中包含给定腿的完成位置。每个支腿都有编号,编号与<legs>
标记中的支腿定义相匹配。
数据如下:
<?xml version="1.0"?>
<results>
<teams>
<team>
<name>
Abu Dhabi Ocean Racing
</name>
<position>
<leg n="1">
1
</leg>
<leg n="2">
3
</leg>
<leg n="3">
2
</leg>
<leg n="4">
2
</leg>
<leg n="5">
1
</leg>
<leg n="6">
2
</leg>
<leg n="7">
5
</leg>
<leg n="8">
3
</leg>
<leg n="9">
5
</leg>
</position>
</team>
...
</teams>
<legs>
...
</legs>
</results>
我们只展示了一队。在这场比赛中一共有七支队伍。
在 XML 表示法中,应用程序数据显示在两种位置。标签之间;例如,<name>Abu Dhabi Ocean Racing</name>
。标记为<name>
,介于<name>
和</name>
之间的文本为该标记的值。
此外,数据显示为标记的属性。例如,在<leg n="1">
中。标签为<leg>
;标签有一个属性n
,其值为1
。标记可以具有不确定数量的属性。
<leg>
标记包括作为属性给出的腿号n
,以及作为标记内文本给出的腿中的位置。一般的方法是将重要数据放在标记中,并在属性中添加补充或澄清数据。两者之间的界限非常模糊。
XML 允许混合内容模型。这反映了当 XML 与文本混合时,XML 标记内外都会有文本。下面是一个混合内容的示例:
<p>This has <strong>mixed</strong> content.</p>
一些文本位于<p>
标记内,一些文本位于<strong>
标记内。<p>
标记的内容是文本和带有更多文本的标记的混合。
我们将使用xml.etree
模块解析数据。这涉及从文件中读取数据并将其提供给解析器。由此产生的文件将相当复杂。
我们没有为样本数据提供正式的模式定义,也没有提供文档类型定义(DTD。这意味着 XML 默认为混合内容模式。此外,XML 结构不能根据模式或 DTD 进行验证。
-
We'll need two modules—
xml.etree
andpathlib
:>>> import xml.etree.ElementTree as XML >>> from pathlib import Path
我们已将
ElementTree
模块名称更改为XML
,以使其更易于键入。将其重命名为类似于ET
的名称也很常见。 -
定义一个定位源文档的
Path
对象:>>> source_path = Path("code/race_result.xml")
-
通过解析源文件
>>> source_text = source_path.read_text(encoding='UTF-8') >>> document = XML.fromstring(source_text)
创建文档的内部
ElementTree
版本
XML 解析器不容易处理Path
对象。我们选择从Path
对象读取文本,然后解析该文本。
一旦我们有了文档,我们就可以在其中搜索相关的数据。在本例中,我们将使用find()
方法定位给定标记的第一个实例:
>>> teams = document.find('teams')
>>> name = teams.find('team').find('name')
>>> name.text.strip()
'Abu Dhabi Ocean Racing'
在本例中,我们定位了<teams>
标记,然后在该列表中找到了<team>
标记的第一个实例。在<team>
标记中,我们定位了第一个<name>
标记以获取团队名称的值。
由于 XML 是一种混合内容模型,因此内容中的所有\n
、\t
和空格字符都在数据中得到了完美的保留。我们很少需要这些空白,使用strip()
方法删除有意义内容前后的所有无关字符是有意义的。
XML 解析器模块基于文档对象模型将 XML 文档转换为相当复杂的对象。在etree
模块中,文档将由通常表示标记和文本的Element
对象构建。
XML 还包括处理指令和注释。这些通常被许多 XML 处理应用程序忽略。
XML 解析器通常有两个操作级别。在底层,他们识别事件。解析器发现的事件包括元素开始、元素结束、注释开始、注释结束、文本运行和类似的词汇对象。在更高的级别上,事件用于构建文档的各种Elements
。
每个Element
实例都有一个标记、文本、属性和尾部。标签是<tag>
中的名称。属性是标记名后面的字段。例如,<leg n="1">
标记的标记名为leg
,属性名为n
。值始终是 XML 中的字符串。
文本包含在标记的开始和结束之间。因此,像<name>Team SCA</name>
这样的标签对于表示<name>
标签的Element
的text
属性的值具有"Team SCA"
。
请注意,标记还有一个 tail 属性:
<name>Team SCA</name>
<position>...</position>
在结束的</name>
标记后面和<position>
标记打开之前有一个\n
字符。这是<name>
标签的尾部。使用混合内容模型时,尾部值可能很重要。在非混合内容模型中工作时,尾值通常是空白。
因为我们不能简单地将 XML 文档转换为 Python 字典,所以我们需要一种方便的方法来搜索文档内容。ElementTree
模块提供了一种搜索技术,它是XML 路径语言(XPath的部分实现,用于在 XML 文档中指定位置。XPath 符号为我们提供了相当大的灵活性。
XPath 查询与find()
和findall()
方法一起使用。以下是我们可以找到所有名称的方法:
>>> for tag in document.findall('teams/team/name'):
... print(tag.text.strip())
Abu Dhabi Ocean Racing
Team Brunel
Dongfeng Race Team
MAPFRE
Team Alvimedica
Team SCA
Team Vestas Wind
我们已经找到了顶级的<teams>
标签。在这个标签中,我们需要<team>
标签。在这些标签中,我们需要<name>
标签。这将搜索此嵌套标记结构的所有实例。
我们也可以搜索属性值。这可以方便地找到所有车队在比赛的某一特定赛段的表现。数据在每个团队的<position>
标签内的<leg>
标签中找到。
此外,每个<leg>
都有一个属性值 n,表示它代表的是哪条赛道。下面是我们如何使用它从 XML 文档中提取特定数据的方法:
>>> for tag in document.findall("teams/team/position/leg[@n='8']"):
... print(tag.text.strip())
3
5
7
4
6
1
2
这向我们展示了各队在比赛第 8 回合的终点位置。我们正在查找带有<leg n="8">
的所有标记,并显示该标记中的文本。我们必须将这些值与车队名称进行匹配,以确保 SCA 车队首先完成比赛,东风车队在这一站最后完成比赛。
- 读取 HTML 文档配方显示了我们如何从 HTML 源准备这些数据
Web 上的大量内容是使用 HTML 标记呈现的。浏览器可以很好地呈现数据。我们如何解析这些数据以从显示的网页中提取有意义的内容?
我们可以使用标准库html.parser
模块,但它没有帮助。它只提供低级词汇扫描信息,但不提供描述原始网页的高级数据结构。
我们将使用 Beauty Soup 模块解析 HTML 页面。这可从Python 包索引(PyPI中获得)。参见https://pypi.python.org/pypi/beautifulsoup4 。
这必须下载并安装才能有用。通常,pip
命令很好地完成了这项工作。
通常情况下,这很简单,如下所示:
pip install beautifulsoup4
对于 Mac OS X 和 Linux 用户,需要使用sudo
命令升级用户权限:
sudo pip install beautifulsoup4
这将提示输入用户的密码。用户必须能够提升自己以拥有 root 权限。
在极少数情况下,您有多个 Python 版本,请确保使用匹配的 pip 版本。在某些情况下,我们可能必须使用以下方法:
sudo pip3.5 install beautifulsoup4
使用 Python 3.5 附带的pip
。
我们在Volvo Ocean Race.html
中收集了一些帆船比赛的结果。此文件包含有关团队、腿以及各个团队完成每一腿的顺序的信息。它是从沃尔沃海洋竞赛网站上刮下来的,在浏览器中打开时看起来很棒。
HTML 表示法与 XML 非常相似。内容周围有<tag>
标记,显示数据的结构和表示。HTML 早于 XML,XHTML 标准协调了这两种浏览器;但是,必须容忍较旧的 HTML,甚至是结构不正确的 HTML。受损 HTML 的存在会使分析万维网数据变得困难。
HTML 页面包含大量开销。通常有大量的代码和样式表部分,以及不可见的元数据。内容可能被广告和其他信息包围。通常,HTML 页面具有以下总体结构:
<html>
<head>...</head>
<body>...</body>
</html>
在<head>
标记中,将有指向 JavaScript 库的链接,以及指向级联样式表(CSS文档的链接。它们通常用于提供交互式功能和定义内容的表示。
大部分内容在<body>
标签中。许多网页非常繁忙,提供了极其复杂的内容组合。网页的设计是一门复杂的艺术,内容的设计在大多数浏览器上都很好看。跟踪网页上的相关数据可能会很困难,因为重点是人们如何看待数据,而不是自动化工具如何处理数据。
在本例中,比赛结果位于 HTML<table>
标记中,因此很容易找到。我们看到的是页面中相关内容的以下总体结构:
<table>
<thead>
<tr>
<th>...</th>
...
</tr>
</thead>
<tbody>
<tr>
<td>...</td>
...
</tr>
...
</tbody>
</table>
<thead>
标记包括表格的列标题。有一个表行标记<tr>
,带有表标题<th>
,包含内容的标记。内容分为两部分;最基本的显示是比赛每一站的数字。这是标记的内容。除了显示的内容外,还有一个 JavaScript 函数使用的属性值。当光标悬停在列标题上时,将显示此属性值。JavaScript 函数会弹出腿部名称。
<tbody>
标签包括球队名称和每场比赛的结果。表格行(<tr>
包含每个团队的详细信息。团队名称(以及图形和整体完成排名)显示在表格数据的前三列<td>
。表格数据的其余列包含给定赛段的终点位置。
由于帆船比赛的相对复杂性,在一些表格数据单元格中有额外的注释。这些属性包括在属性中,用于提供有关单元格值原因的补充数据。在某些情况下,团队没有开始一段比赛,或者没有完成一段比赛,或者退出一段比赛。
下面是 HTML 中一个典型的<tr>
行:
<tr class="ranking-item">
<td class="ranking-position">3</td>
<td class="ranking-avatar">
<img src="..."> </td>
<td class="ranking-team">Dongfeng Race Team</td>
<td class="ranking-number">2</td>
<td class="ranking-number">2</td>
<td class="ranking-number">1</td>
<td class="ranking-number">3</td>
<td class="ranking-number" tooltipster data-></td>
<td class="ranking-number">1</td>
<td class="ranking-number">4</td>
<td class="ranking-number">7</td>
<td class="ranking-number">4</td>
<td class="ranking-number total">33<span class="asterix">*</span></td>
</tr>
<tr>
标记有一个 class 属性,用于定义此行的样式。CSS 提供此类数据的样式规则。此标记上的class
属性有助于我们的数据收集应用程序定位相关内容。
<td>
标记还具有类属性,用于定义数据的各个单元格的样式。在这种情况下,类信息澄清了单元格内容的含义。
其中一个单元格没有内容。该单元格的属性为data-title
。JavaScript 函数使用它来显示单元格中的其他信息。
-
We'll need two modules: bs4 and pathlib:
>>> from bs4 import BeautifulSoup >>> from pathlib import Path
我们只从
bs4
模块导入了BeautifulSoup
类。此类将提供解析和分析 HTML 文档所需的所有功能。 -
定义一个命名源文档的
Path
对象:>>> source_path = Path("code/Volvo Ocean Race.html")
-
从 HTML 内容创建 soup 结构。我们将它分配给一个变量,
soup
:>>> with source_path.open(encoding='utf8') as source_file: ... soup = BeautifulSoup(source_file, 'html.parser')
我们使用了上下文管理器来访问该文件。作为替代方案,我们可以简单地用source_path.read_text(encodig='utf8')
阅读内容。这与为BeautifulSoup
类提供一个打开的文件一样有效。
然后可以处理变量soup
中的汤结构,以定位不同的内容。例如,我们可以提取腿部细节,如下所示:
def get_legs(soup)
legs = []
thead = soup.table.thead.tr
for tag in thead.find_all('th'):
if 'data-title' in tag.attrs:
leg_description_text = clean_leg(tag.attrs['data-title'])
legs.append(leg_description_text)
return legs
表达式soup.table.thead.tr
将找到第一个<table>
标记。其中,第一个<thead>
标签;在这里面,第一个<tr>
标签。我们将这个<tr>
标记赋给一个名为thead
的变量,可能有误导性。然后我们可以进行findall()
定位此容器中的所有<th>
标签。
我们将检查每个标记的属性以定位data-title
属性值。这将包含腿部名称信息。腿部名称内容如下所示:
<th tooltipster data->LEG 1</th>
data-title
属性值在该值中包含一些额外的 HTML 标记。这不是 HTML 的标准部分,BeautifulSoup
解析器不会在属性值中查找此 HTML。
我们需要解析一小段 HTML,因此我们可以创建一个小的soup
对象来解析这段文本:
def clean_leg(text):
leg_soup = BeautifulSoup(text, 'html.parser')
return leg_soup.text
我们仅根据data-title
属性的值创建一个小BeautifulSoup
对象。这道汤将包含关于标签<strong>
和文本的信息。我们使用 text 属性获取所有文本,而不包含任何标记信息。
BeautifulSoup
类基于文档对象模型(DOM)将 HTML 文档转换为相当复杂的对象。生成的结构将根据Tag
、NavigableString
和Comment
类的实例构建。
通常,我们对包含网页字符串内容的标记感兴趣。这些是Tag
和NavigableString
类的对象。
每个Tag
实例都有一个名称、字符串和属性。名称为<
和>
中的单词。属性是标记名后面的字段。例如,<td class="ranking-number">1</td>
的标记名为td
,属性名为class
。值通常是字符串,但在少数情况下,值可以是字符串列表。Tag
对象的 string 属性是标签所包含的内容;在本例中,它是一个非常短的字符串,1
。
HTML 是一种混合内容模型。这意味着标记除了可导航文本外,还可以包含子标记。文本是混合的,它可以在任何子标记的内部或外部。当查看给定标记的子项时,将有一系列标记和文本自由混合。
HTML 最常见的特性之一是只包含换行符的小块可导航文本。当我们喝这样的汤时:
<tr>
<td>Data</td>
</tr>
<tr>
标签中有三个孩子。以下是此标记的子项的显示:
>>> example = BeautifulSoup('''
... <tr>
... <td>data</td>
... </tr>
... ''', 'html.parser')
>>> list(example.tr.children)
['\n', <td>data</td>, '\n']
这两个换行符与<td>
标记对等,并由解析器保留。这是围绕子标记的可导航文本。
BeautifulSoup
解析器依赖于另一个较低级别的进程。下层流程可以是内置的html.parser
模块。也可以安装一些替代方案。html.parser
最容易使用,涵盖了最常见的用例。还有其他选择,Beautiful Soup 文档列出了其他可用于解决特定 web 解析问题的低级解析器。
低级解析器识别事件;这些包括元素开始、元素结束、注释开始、注释结束、文本运行和类似的词汇对象。在更高的级别上,事件用于构建 Beauty Soup 文档的各种对象。
Beautiful Soup 的Tag
对象表示文档结构的层次结构。标签之间有几种导航方式:
- 除特殊根
[document]
容器外的所有标记都将有父标记。顶部的<html>
标记通常是根文档容器的唯一子级。 parents
属性是标记所有父项的生成器。它是通过层次结构到给定标记的路径。- 所有
Tag
对象都可以有子对象。一些标签,如<img/>
和<hr/>
没有子项。children
属性是一个生成标记子项的生成器。 - 带有子项的标记下可能有多个级别的标记。例如,整个
<html>
标记将整个文档作为子体。children
属性具有直接子级;descendants
属性生成子对象的所有子对象。 - 标记也可以有同级标记,同级标记是同一容器中的其他标记。由于标记有一个定义的顺序,因此有一个
next_sibling
和previous_sibling
属性来帮助遍历标记的对等点。
在某些情况下,文档的组织结构通常是直截了当的,通过id
属性或class
属性进行简单搜索即可找到相关数据。以下是对给定结构的典型搜索:
>>> ranking_table = soup.find('table', class_="ranking-list")
注意,我们必须在 Python 查询中使用class_
来搜索名为class
的属性。考虑到整个文档,我们正在搜索任何<table class="ranking-list">
标记。这将在网页中找到第一个这样的表。因为我们知道其中只有一个,所以这种基于属性的搜索有助于区分网页上的任何其他表格数据。
下面是这个<table>
标签的父母:
>>> list(tag.name for tag in ranking_table.parents)
['section', 'div', 'div', 'div', 'div', 'body', 'html', '[document]']
我们只在给定的<table>
上方显示了每个父项的标记名。请注意,有四个嵌套的<div>
标记,用于包装包含<table>
的<section>
。这些<div>
标记中的每一个都可能有一个不同的类属性来正确定义内容和内容样式。
[document]
是整个BeautifulSoup
容器,其中包含解析的各种标记。它以独特的方式显示,以强调它不是真正的标记,而是顶级<html>
标记的容器。
- 读取 JSON 文档和读取 XML 文档的方法都使用类似的数据。示例数据是通过使用这些技术对 HTML 页面进行刮削而创建的。
当我们从 CSV 格式文件中读取数据时,我们有两个用于生成数据结构的常规选择:
- 当我们使用
csv.reader()
时,每一行变成一个简单的列值列表。 - 当我们使用
csv.DictReader
时,每一行都成为一本字典。默认情况下,第一行的内容将成为行字典的键。另一种方法是提供将用作键的值列表。
在这两种情况下,在行中引用数据都很尴尬,因为它涉及相当复杂的语法。当我们使用csv
阅读器时,我们必须使用row[2]
:其语义完全模糊。当我们使用DictReader
时,我们可以使用row['date']
,它不那么晦涩,但仍然需要大量的打字。
在一些真实的电子表格中,列名是难以置信的长字符串。与row['Total of all locations excluding franchisees']
一起工作很难。
我们可以做些什么来用更简单的语法替换复杂的语法?
提高电子表格程序可读性的一种方法是用namedtuple
对象替换列列表。这提供了由namedtuple
定义的易于使用的名称,而不是.csv
文件中可能不规则的列名。
更重要的是,它允许使用更好的语法来引用各个列。除了row[0]
之外,我们还可以使用row.date
来引用名为date
的列。
列名(以及每列的数据类型)是给定数据文件架构的一部分。在某些 CSV 文件中,列标题的第一行是文件的模式。这个模式是有限的,它只提供属性名;数据类型未知,必须作为字符串处理。
这说明了在电子表格的行上强制使用外部模式的两个原因:
- 我们可以提供有意义的名字
- 我们可以在必要时进行数据转换
我们将查看一个相对简单的 CSV 文件,其中包含从帆船日志中记录的一些实时数据。这是waypoints.csv
文件,数据如下:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
数据有四列。其中两列是航路点的纬度和经度。它有一列,日期和时间作为单独的值。这并不理想,我们将分别研究各种数据清理步骤。
在本例中,列标题恰好是有效的 Python 变量名。这是罕见的,但它可以导致一个轻微的简化。我们将在下一节中查看替代方案。
最重要的步骤是将数据收集为namedtuples
。
-
导入所需的模块和定义。在这种情况下,它们将来自
collections
、csv
和pathlib
:from collections import namedtuple from pathlib import Path import csv
-
定义与实际数据匹配的
namedtuple
。在本例中,我们将其命名为Waypoint
,并提供了四列数据的名称。在本例中,属性恰好与列名匹配;不要求名称匹配:Waypoint = namedtuple('Waypoint', ['lat', 'lon', 'date', 'time'])
-
定义引用数据的
Path
对象:waypoints_path = Path('waypoints.csv')
-
为打开的文件创建处理上下文:
with waypoints_path.open() as waypoints_file:
-
为数据定义 CSV 读取器。我们称之为原始读取器。从长远来看,我们将遵循第 8 章中的使用堆叠生成器表达式配方、功能性和反应性编程特性以及第 8 章中的使用堆叠生成器表达式配方、功能性和反应性编程功能用于清理和过滤数据:
raw_reader = csv.reader(waypoints_file)
-
定义一个生成器,该生成器根据输入数据的元组构建
Waypoint
对象:waypoints_reader = (Waypoint(*row) for row in raw_reader)
我们现在可以使用waypoints_reader
生成器表达式处理行:
for row in waypoints_reader:
print(row.lat, row.lon, row.date, row.time)
waypoints_reader
对象还将提供我们希望忽略的标题行。我们将在下一节中介绍过滤和转换。
表达式(Waypoint(*row) for row in raw_reader)
将row
元组的每个值展开为Waypoint
函数的位置参数值。这是因为 CSV 文件中的列顺序与namedtuple
定义中的列顺序匹配。
也可以使用itertools
模块执行此构造。starmap()
功能可作为starmap(Waypoint, raw_reader)
使用。这也会将每个元组从raw_reader
扩展为Waypoint
函数的位置参数。请注意,我们无法使用内置的map()
功能进行此操作。map()
函数假定该函数采用单个参数值。我们不希望每个四项row
元组都用作Waypoint
函数的唯一参数。我们需要将这四个项拆分为四个位置参数值。
这个食谱有几个部分。首先,我们使用csv
模块对数据的行和列进行必要的解析。我们利用 cvs 模块配方中的读取分隔文件来处理数据的物理格式。
其次,我们定义了一个namedtuple()
,它为我们的数据提供了一个最小的模式。这不是很丰富或详细。它提供了一系列列名。它还简化了访问特定列的语法。
最后,我们将csv
读取器包装在一个生成器函数中,为每一行构建namedtuple
对象。这是对默认处理的微小更改,但它为后续编程带来了更好的风格。
我们现在可以使用row.date
来引用特定列,而不是row[2]
或row['date']
。这是一个可以简化复杂算法表示的小更改。
处理输入的初始示例还有两个附加问题。首先,将标题行与有用的数据行混合;此标题行需要被某种筛选器拒绝。其次,数据都是字符串,需要进行一些转换。我们将通过扩展配方来解决这些问题。
丢弃不需要的标题行有两种常用技术:
-
We can use an explicit iterator and discard the first item. The general idea is as follows:
with waypoints_path.open() as waypoints_file: raw_reader = csv.reader(waypoints_file) waypoints_iter = iter(waypoints_reader) next(waypoints_iter) # The header for row in waypoints_iter: print(row)
此代码段显示如何从原始 CSV 读取器创建迭代器对象
waypoints_iter
。我们可以使用next()
函数跳过此读卡器中的单个项目。其余项目可用于构建有用的数据行。我们也可以使用itertools.islice()
功能来实现此目的。 -
我们可以编写生成器或使用
filter()
函数排除所选行:with waypoints_path.open() as waypoints_file: raw_reader = csv.reader(waypoints_file) skip_header = filter(lambda row: row[0] != 'lat', raw_reader) waypoints_reader = (Waypoint(*row) for row in skip_header) for row in waypoints_reader: print(row)
此示例显示如何从原始 CSV 读取器创建过滤生成器skip_header
。过滤器使用一个简单的表达式row[0] != 'lat'
来确定一行是标题还是有有用的数据。此筛选器只传递有用的行。标题行被拒绝。
我们需要做的另一件事是将各种数据项转换为更有用的值。我们将遵循第 8 章功能性和反应性编程特性中使用不可变数据结构简化复杂算法配方的示例,并根据原始输入数据构建一个新的namedtuple
:
Waypoint_Data = namedtuple('Waypoint_Data', ['lat', 'lon', 'timestamp'])
在大多数项目中,很明显,Waypoint namedtuple
的原始名称选择不当。需要对代码进行重构,以更改名称,从而明确原始Waypoint
元组的角色。随着设计的发展,这种重命名和重构将发生多次。根据需要对事物进行重命名是很重要的。我们不会在这里进行重命名:我们将留给读者重新设计名称。
要进行转换,我们需要一个函数来处理单个Waypoint
的各个字段。这将创建更多有用的值。这将涉及在纬度和经度值上使用float()
。它还需要仔细分析日期值。
下面是使用单独日期和时间的第一部分。这是两个 lambda 对象小函数,只有一个表达式可以将日期或时间字符串转换为日期或时间值:
import datetime
parse_date = lambda txt: datetime.datetime.strptime(txt, '%Y-%m-%d').date()
parse_time = lambda txt: datetime.datetime.strptime(txt, '%H:%M:%S').time()
我们可以使用这些从原始的Waypoint
对象构建一个新的Waypoint_data
对象:
def convert_waypoint(waypoint):
return Waypoint_Data(
lat = float(waypoint.lat),
lon = float(waypoint.lon),
timestamp = datetime.datetime.combine(
parse_date(waypoint.date),
parse_time(waypoint.time)
)
)
我们应用了一系列函数,这些函数从现有数据结构构建新的数据结构。纬度和经度值通过float()
函数进行转换。使用datetime
类的combine()
方法,使用parse_date
和parse_time
lambdas 将日期和时间值转换为datetime
对象。
此函数允许我们为源数据构建更完整的处理步骤堆栈:
with waypoints_path.open() as waypoints_file:
raw_reader = csv.reader(waypoints_file)
skip_header = filter(lambda row: row[0] != 'lat', raw_reader)
waypoints_reader = (Waypoint(*row) for row in skip_header)
waypoints_data_reader = (convert_waypoint(wp) for wp in waypoints_reader)
for row in waypoints_data_reader:
print(row.lat, row.lon, row.timestamp)
原始阅读器补充了一个过滤函数来跳过标题,一个生成器来创建Waypoint
对象,另一个生成器来创建Waypoint_Data
对象。在for
语句的主体中,我们有一个简单易用的数据结构,并有令人愉快的名称。我们可以参考row.lat
而不是row[0]
或row['lat']
。
请注意,每个生成器函数都是惰性的,它获取的输入不会超过生成某些输出所需的最低限度。这个生成器函数堆栈使用很少的内存,可以处理无限大小的文件。
- 将 CSV 从 dict 读取器升级到名称空间读取器的方法使用可变
SimpleNamespace
数据结构实现了这一点
当我们从 CSV 格式文件中读取数据时,我们有两个用于生成数据结构的常规选择:
- 当我们使用
csv.reader()
时,每一行变成一个简单的列值列表。 - 当我们使用
csv.DictReader
时,每一行都成为一本字典。默认情况下,第一行的内容将成为行字典的键。我们还可以提供将用作键的值的列表。
在这两种情况下,在行中引用数据都很尴尬,因为它涉及相当复杂的语法。当我们使用阅读器时,我们必须使用row[0]
,其语义是完全模糊的。当我们使用DictReader
时,我们可以使用row['date']
,它不那么晦涩,但需要大量的打字。
在一些真实的电子表格中,列名是难以置信的长字符串。与row['Total of all locations excluding franchisees']
一起工作很难。
我们可以做些什么来用更简单的语法替换复杂的语法?
列名(以及每列的数据类型)是数据的模式。列标题是嵌入 CSV 数据第一行的模式。该模式仅提供属性名称;数据类型未知,必须作为字符串处理。
这指出了在电子表格的行上强制使用外部模式的两个原因:
- 我们可以提供有意义的名称。
- 我们可以在必要时进行数据转换。
我们还可以使用模式来定义数据质量和清理处理。这可能变得相当复杂。我们将限制模式的使用,以提供列名和数据转换。
我们将查看一个相对简单的 CSV 文件,其中包含从帆船日志中记录的一些实时数据。这是waypoints.csv
文件。数据如下所示:
lat,lon,date,time
32.8321666666667,-79.9338333333333,2012-11-27,09:15:00
31.6714833333333,-80.93325,2012-11-28,00:00:00
30.7171666666667,-81.5525,2012-11-28,11:35:00
此电子表格有四列。其中两个是航路点的纬度和经度。它有一列,日期和时间作为单独的值。这并不理想,我们将分别研究各种数据清理步骤。
在本例中,列标题是有效的 Python 变量名。这导致了处理过程的重要简化。在没有列名或列名不是 Python 变量的情况下,我们必须应用从列名到首选属性名的映射。
-
导入所需的模块和定义。在这种情况下,它将从
types
、csv
和pathlib
:from types import SimpleNamespace from pathlib import Path
开始
-
导入
csv
并定义引用数据的Path
对象:waypoints_path = Path('waypoints.csv')
-
为打开的文件创建处理上下文:
with waypoints_path.open() as waypoints_file:
-
为数据定义 CSV 读取器。我们称之为原始读取器。从长远来看,我们将遵循第 8 章中的使用堆叠生成器表达式配方、功能性和反应性编程特性,并使用多个生成器表达式来清理和过滤数据:
raw_reader = csv.DictReader(waypoints_file)
-
Define a generator that will convert these dictionaries into
SimpleNamespace
objects:ns_reader = (SimpleNamespace(**row) for row in raw_reader)
它使用泛型
SimpleNamespace
类。当我们需要使用更具体的类时,我们可以用特定于应用程序的类名替换SimpleNamespace
。该类__init__
必须使用与电子表格列名匹配的关键字参数。
现在,我们可以处理此生成器表达式中的行:
for row in ns_reader:
print(row.lat, row.lon, row.date, row.time)
这个食谱有几个部分。首先,我们使用csv
模块对数据的行和列进行必要的解析。我们利用 cvs 模块配方中的读取分隔文件来处理数据的物理格式。CSV 格式的思想是在每一行中有逗号分隔的文本列。使用引号允许列中的数据包含逗号有一些规则。这些规则都是在csv
模块中实现的,这样我们就不用为此编写解析器了。
其次,我们将csv
读取器包装在一个生成器函数中,为每一行构建一个SimpleNamespace
对象。这是对默认处理的一个小小扩展,但它为后续编程带来了更好的风格。我们现在可以使用row.date
来引用特定列,而不是row[2]
或row['date']
。这是一个可以简化复杂算法表示的小更改。
我们可能还有两个问题要解决。是否需要这些取决于数据和数据的用途:
- 我们如何处理不是正确的 Python 变量的电子表格名称?
- 如何将数据从文本转换为 Python 对象?
事实证明,这两种需求都可以通过一个函数优雅地处理,该函数可以逐行转换数据,还可以处理任何必要的列重命名:
def make_row(source):
return SimpleNamespace(
lat = float(source['lat']),
lon = float(source['lon']),
timestamp = make_timestamp(source['date'], source['time']),
)
此函数实际上是原始电子表格的架构定义。此函数中的每一行都提供了几条重要信息:
SimpleNamespace
中的属性名称- 源数据的转换
- 映射到最终结果的源列名
目标是定义所需的任何帮助器或支持函数,以确保转换函数的每一行与所示的类似。此函数的每一行都是结果列的完整规范。作为额外的好处,每一行都是用 Python 表示法编写的。
此函数可以替换ns_reader
语句中的SimpleNamespace
。所有转换工作现在都集中在一个地方:
ns_reader = (make_row(row) for row in raw_reader)
该行转换函数依赖于一个make_timestamp()
函数。此函数将两个源列转换为一个生成的datetime
对象。该函数如下所示:
import datetime
make_date = lambda txt: datetime.datetime.strptime(
txt, '%Y-%m-%d').date()
make_time = lambda txt: datetime.datetime.strptime(
txt, '%H:%M:%S').time()
def make_timestamp(date, time):
return datetime.datetime.combine(
make_date(date),
make_time(time)
)
make_timestamp()
函数将时间戳创建分为三个部分。前两部分非常简单,只需要一个 lambda 对象。这些是从文本到生成datetime.date
或datetime.time
对象的转换。每次转换都使用strptime()
方法解析日期或时间字符串,并返回相应的对象类。
第三部分也可能是 lambda,因为它也是一个表达式。然而,这是一个很长的表达,用def
语句来包装似乎更清晰一些。此表达式使用datetime
的combine()
方法将日期和时间组合为单个对象。
- 将 CSV 从 dict 读卡器升级到 namedtuple 读卡器的方法是使用不可变的
namedtuple
数据结构,而不是SimpleNamespace
数据结构
通常需要将数据从一种格式转换为另一种格式。例如,我们可能有一个复杂的 web 日志,希望将其转换为更简单的格式。
有关复杂的 web 日志格式,请参见使用正则表达式读取复杂格式配方。我们只想做一次解析。
之后,我们希望使用更简单的文件格式,更像将 CSV 从 dict 读取器升级到 namedtuple 读取器或将 CSV 从 dict 读取器升级到 namespace 读取器配方中所示的格式。CSV 表示法的文件可以通过csv
模块读取和解析,从而简化了物理格式方面的考虑。
我们如何从一种格式转换到另一种格式?
将数据文件从一种格式转换为另一种格式意味着程序需要有两个开放的上下文:一个用于读取,一个用于写入。Python 使这变得简单。with
语句上下文的使用确保文件正确关闭,所有相关操作系统资源完全释放。
我们将研究总结许多 web 日志文件的常见问题。源代码的格式正如我们在第 8 章中使用收益声明配方编写生成器函数、函数和反应式编程特性以及本章中使用正则表达式配方读取复杂格式所看到的格式。这些行如下所示:
[2016-05-08 11:08:18,651] INFO in ch09_r09: Sample Message One
[2016-05-08 11:08:18,651] DEBUG in ch09_r09: Debugging
[2016-05-08 11:08:18,652] WARNING in ch09_r09: Something might have gone wrong
这些都很难处理。解析它们所需的正则表达式很复杂。对于大量数据,它的速度也相当慢。
下面是该行各个元素的正则表达式模式:
import re
pattern_text = (r'\[(?P<date>\d+-\d+-\d+ \d+:\d+:\d+,\d+)\]'
'\s+(?P<level>\w+)'
'\s+in\s+(?P<module>[\w_\.]+):'
'\s+(?P<message>.*)')
pattern = re.compile(pattern_text)
此复杂正则表达式包含四个部分:
- 日期时间戳被
[ ]
包围,有各种数字、连字符、冒号和逗号。它将被捕获并通过()
组上的?P<date>
前缀分配名称date
。 - 严重性级别,它是一系列字符。这将被捕获,并通过下一个
()
组的?P<level>
前缀指定名称级别。 - 模块是一个字符序列,包括
_
和.
。它夹在in
和:
之间。已将名称指定为module
。 - 最后,有一条消息一直延伸到这行的末尾。这是由最终的
()
中的?P<message>
分配给消息的。
该模式还包括空白的运行\s+
,这在任何()
组中都不会被捕获。他们被悄悄地忽视了。
当我们使用这个正则表达式创建一个match
对象时,该match
对象的groupdict()
方法将生成一个包含每行名称和值的字典。这与csv
阅读器的工作方式相匹配。它提供了处理复杂数据的通用框架。
我们将在遍历日志数据行的函数中使用它。该函数将应用正则表达式,并生成组字典:
def extract_row_iter(source_log_file):
for line in source_log_file:
match = log_pattern.match(line)
if match is None:
# Might want to write a warning
continue
yield match.groupdict()
此函数查看给定输入文件中的每一行。它将正则表达式应用于该行。如果行匹配,这将捕获数据的相关字段。如果不匹配,则表示该行未遵循预期格式;这可能需要一条错误消息。没有有用的数据可供生成,因此continue
语句跳过了for
语句主体的其余部分。
yield
语句生成匹配的字典。每个字典都有四个命名字段和从日志中捕获的数据。数据仅为文本,因此必须单独应用其他转换。
我们可以使用csv
模块中的DictWriter
类发出一个 CSV 文件,将这些不同的数据元素整齐地分开。一旦我们创建了 CSV 文件,我们就可以比原始日志行更简单、更快地处理数据。
-
此配方将需要三种成分:
import re from pathlib import Path import csv
-
这是与简单烧瓶日志匹配的模式。对于其他类型的日志,或配置到烧瓶中的其他格式,将需要不同的模式:
log_pattern = re.compile( r"\[(?P<timestamp>.*?)\]" r"\s(?P<levelname>\w+)" r"\sin\s(?P<module>[\w\._]+):" r"\s(?P<message>.*)")
-
下面是为匹配行生成字典的函数。这将应用正则表达式模式。将自动跳过非匹配项。匹配将生成项目名称及其值的字典:
def extract_row_iter(source_log_file): for line in source_log_file: match = log_pattern.match(line) if match is None: continue yield match.groupdict()
-
我们将为生成的日志摘要文件定义
Path
对象:summary_path = Path('summary_log.csv')
-
然后我们可以打开结果上下文。因为我们使用的是一个
with
语句,所以我们可以保证,无论在这个脚本with summary_path.open('w') as summary_file:
中发生了什么,该文件都将被正确关闭
-
因为我们正在基于字典编写 CSV 文件,所以我们将定义一个
csv.DictWriter
。with
语句中有四个空格缩进。我们必须从输入字典中提供预期的键。这将定义结果文件中列的顺序:writer = csv.DictWriter(summary_file, ['timestamp', 'levelname', 'module', 'message']) writer.writeheader()
-
We'll define a
Path
object for the source directory with log files. In this case, the log files happen to be in the directory with the script. This is rare, and using an environment variable might be a lot more useful:source_log_dir = Path('.')
我们可以想象使用
os.environ.get('LOG_PATH', '/var/log')
作为比硬编码路径更通用的解决方案。 -
We'll use the
glob()
method of aPath
object to find all files that match the required name:for source_log_path in source_log_dir.glob('*.log'):
从环境变量或命令行参数中获取模式字符串也会带来好处。
-
我们将为读取每个源文件定义一个上下文。这个上下文管理器将保证输入文件被正确关闭,资源被释放。请注意,这在前面的
with
和for
语句中缩进,共有八个空格。这在处理大量文件时尤为重要:with source_log_path.open() as source_log_file:
-
我们将使用 writer 的
writerows()
方法写入extract_row_iter()
函数中的所有有效行。这在with
语句和for
语句中缩进。这是流程的核心:
```py
writer.writerows(extract_row_iter(source_log_file) )
```
- 我们也可以写一个总结。这在外部缩进,带有和
for
语句。用语句
```py
print('Converted', source_log_path, 'to', summary_path)
```
总结前面的处理
Python 可以很好地与多个上下文管理器配合使用。我们可以很容易地得到嵌套很深的with
语句。每个with
语句都可以管理不同的上下文对象。
由于打开的文件是上下文对象,因此最好将每个打开的文件包装在一个with
语句中,以确保文件已正确关闭,并且所有操作系统资源都已从文件中释放。
我们使用了Path
对象来表示文件系统位置。这使我们能够根据输入名称轻松创建输出名称,或者在处理文件后重命名文件。有关这方面的更多信息,请参阅使用 pathlib 处理文件名的配方。
我们使用了一个生成器函数来组合两个操作。首先,有一个从源文本到单个字段的映射。其次,有一个过滤器排除与预期模式不匹配的源文本。在许多情况下,我们可以使用map()
和filter()
函数使这一点更加清楚。
使用正则表达式匹配时;但是,分离操作的映射和过滤部分并不容易。正则表达式可能与某些输入行不匹配,这将成为一种绑定到映射中的过滤。因此,生成函数的计算结果非常好。
csv
作者有writerows()
方法。此方法接受迭代器作为其参数值。这使得为编写器提供生成器功能变得容易。编写器将在生成器生成对象时使用这些对象。非常大的文件可以通过这种方式处理,因为整个文件不读入内存,只读取足够的文件来创建完整的数据行。
通常需要对从每个源读取的日志文件行数、由于不匹配而丢弃的行数以及最终写入摘要文件的行数进行摘要计数。
使用发电机时,这是一个挑战。生成器生成大量的数据行。它如何也能产生一个摘要?
答案是我们可以提供一个可变对象作为生成器的参数。理想的可变对象是collections.Counter
的一个实例。我们可以使用它来统计事件,包括有效记录、无效记录,甚至特定数据值的出现。可变对象可以由生成器和整个主程序共享,以便主程序可以将计数信息打印到日志中。
下面是映射过滤器函数,它将文本转换为有用的字典对象。我们编写了第二个版本,名为counting_extract_row_iter()
,以强调附加功能:
def counting_extract_row_iter(counts, source_log_file):
for line in source_log_file:
match = log_pattern.match(line)
if match is None:
counts['non-match'] += 1
continue
counts['valid'] += 1
yield match.groupdict()
我们提供了一个额外的参数,counts
。当我们发现与正则表达式不匹配的行时,我们可以增加Counter
中的non-match
键。当我们找到正确匹配的行时,我们可以增加Counter
中的valid
键。这将提供一个摘要,显示如何处理给定文件中的行。
整个处理脚本如下所示:
summary_path = Path('summary_log.csv')
with summary_path.open('w') as summary_file:
writer = csv.DictWriter(summary_file,
['timestamp', 'levelname', 'module', 'message'])
writer.writeheader()
source_log_dir = Path('.')
for source_log_path in source_log_dir.glob('*.log'):
counts = Counter()
with source_log_path.open() as source_log_file:
writer.writerows(
counting_extract_row_iter(counts, source_log_file)
)
print('Converted', source_log_path, 'to', summary_path)
print(counts)
我们做了三个小改动:
- 在处理源日志文件之前创建一个空的
Counter
对象。 - 向
counting_extract_row_iter()
函数提供Counter
对象。该函数将在处理行时更新计数器。 - 处理完文件后打印
counter
的值。朴素的输出不是很漂亮,但它讲述了一个重要的故事。
我们可能会看到如下输出:
Converted 20160612.log to summary_log.csv
Counter({'valid': 86400})
Converted 20160613.log to summary_log.csv
Counter({'valid': 86399, 'non-match': 1)
这种输出向我们显示了summary_log.csv
的大小,还显示了20160613.log
文件中出现了错误。
我们可以很容易地扩展它,将所有单个源文件计数器组合在一起,以便在进程结束时生成单个大型输出。我们可以使用+
操作符组合多个Counter
对象,以创建所有数据的总和。细节留给读者作为练习。
- 有关上下文的基础知识,请参阅使用上下文管理器读取和写入文件配方